Compare commits

...

86 Commits

Author SHA1 Message Date
Xinrea
b03f0150d8 release: bump version to 1.0.6 2024-10-26 12:43:07 +08:00
Xinrea
d61ddafb44 fix: remove window effects
Tauri currently does not provide an API to retrieve the active window effects, making it impossible to adjust the window style based on that information. Therefore, we have removed the window effects to maintain UI consistency.

close #18
2024-10-26 12:42:41 +08:00
Xinrea
fd89a197a5 release: bump version to 1.0.5 2024-10-25 21:09:36 +08:00
Xinrea
31fa29ee62 fix: shortkey description 2024-10-25 21:08:32 +08:00
Xinrea
c7e28b2ad6 doc: update readme 2024-10-25 21:06:20 +08:00
Xinrea
bbc1343079 chore: fix some clippy warning 2024-10-25 20:19:24 +08:00
Xinrea
c7d4fb270b feat: add text-shadow for danmaku 2024-10-25 01:03:34 +08:00
Xinrea
fcccdee105 release: bump version to 1.0.4 2024-10-24 01:04:36 +08:00
Xinrea
887072f6c7 chore: remove quick-clip for now 2024-10-24 01:04:02 +08:00
Xinrea
1932edba21 feat: remove live-window fullscreen button on windows 2024-10-24 01:01:43 +08:00
Xinrea
0c15415822 feat: delete related cache folder when removing recorder 2024-10-24 01:01:04 +08:00
Xinrea
b8dc0870b5 feat: adjust cover-text style 2024-10-22 02:00:24 +08:00
Xinrea
9d0ad2ae45 feat: hide danmaku when playback is not keeping up with live stream 2024-10-22 01:43:44 +08:00
Xinrea
7278b9f48c fix: remove unused overflow-button 2024-10-22 01:39:22 +08:00
Xinrea
1aee95492a fix: only enable danmaku in live 2024-10-22 01:34:48 +08:00
Xinrea
0cff889f4b release: bump version to 1.0.3 2024-10-21 04:58:03 +08:00
Xinrea
9cd05362ac chore: update dependency ffmpeg-sidecar to 1.2.0 2024-10-21 04:57:24 +08:00
Xinrea
269eccc7ef feat: add playback rate select 2024-10-21 03:43:38 +08:00
Xinrea
aafd02090b feat: live_window title using formatted timestamp to show date 2024-10-21 03:21:53 +08:00
Xinrea
e0e43dbfa4 fix: prevent danmaku overflow from showing scrollbar 2024-10-21 02:39:49 +08:00
Xinrea
37c358a48b fix: video preview modal background 2024-10-21 02:27:07 +08:00
Xinrea
2b81f7a106 release: bump version to 1.0.2 2024-10-20 22:44:39 +08:00
Xinrea
cb9b606cb4 feat: save profile when text changed 2024-10-20 22:43:47 +08:00
Xinrea
c1879c6527 fix: post button 2024-10-20 22:41:15 +08:00
Xinrea
035c54b2fd fix: using player.seekRange() to get video total length 2024-10-20 22:37:23 +08:00
Xinrea
54b152207c fix: video preview window 2024-10-19 14:24:25 +08:00
Xinrea
034e159442 chore: change tray menu text 2024-10-19 14:04:00 +08:00
Xinrea
8a2533c182 fix: missing live_index when releasing 2024-10-19 13:50:14 +08:00
Xinrea
a35cc4cc19 ci/cd: update workflow 2024-10-19 06:05:00 +08:00
Xinrea
75b0cc80e3 feat: add notifications 2024-10-19 04:12:37 +08:00
Xinrea
c39a0bf462 fix: avoid long lock when cache migrating 2024-10-19 04:12:23 +08:00
Xinrea
10f19366f0 feat: add messages for cache's long migration 2024-10-19 03:52:46 +08:00
Xinrea
36ed9025f8 fix: stream not available after cache path changed 2024-10-19 03:50:09 +08:00
Xinrea
f67acc3fe1 fix: config not saved when errors 2024-10-19 03:31:03 +08:00
Xinrea
dea6418d54 fix: config not saving 2024-10-19 03:29:01 +08:00
Xinrea
adf3f63cfe feat: introduce app icon 2024-10-18 20:57:37 +08:00
Xinrea
22b1f11577 feat: simple support for folder migration (output & cache)
close #12
2024-10-18 20:55:38 +08:00
Xinrea
bc9fb48f18 feat: add linux support (untested) 2024-10-18 13:30:27 +08:00
Xinrea
0d6e6cc289 chore: remove unused functions 2024-10-18 01:06:59 +08:00
Xinrea
6d75a50716 feat: instant danmaku display support & danmaku sending
close #14
close #15
2024-10-17 20:11:20 +08:00
Xinrea
a8d9760a11 feat: prompt of empty message list 2024-10-15 04:40:38 +08:00
Xinrea
2889d3634f fix: disk name on windows 2024-10-15 04:31:52 +08:00
Xinrea
958e77e240 fix: message list scrollbar 2024-10-15 04:16:57 +08:00
Xinrea
e36a75aa28 feat: delete video button 2024-10-15 04:15:15 +08:00
Xinrea
1d66852134 feat: add play icon for preview-img-button 2024-10-15 04:01:21 +08:00
Xinrea
aab9764d33 fix: live window scrollbar is over titlebar 2024-10-15 02:10:24 +08:00
Xinrea
cf1c75c5b3 fix: live clip offset
close #11
2024-10-15 01:47:41 +08:00
Xinrea
a9c9d743b8 feat: webid expire-check and update 2024-10-14 19:36:41 +08:00
Xinrea
0213fda9a4 chore: update readme 2024-10-14 18:53:24 +08:00
Xinrea
909867e116 fix: adjust for windows platform 2024-10-14 18:48:21 +08:00
Xinrea
b75f920201 feat: simple disk info display
close #8
2024-10-14 16:57:55 +08:00
Xinrea
18a2fff07b fix: clip preview 2024-10-14 15:53:20 +08:00
Xinrea
1826dd122d fix: avoid code -352 with webid 2024-10-14 13:17:41 +08:00
Xinrea
2cb19941ee feat: add clip video record in db 2024-10-14 13:17:17 +08:00
Xinrea
00cd4d4b72 feat: store profiles in local storage 2024-10-10 12:16:28 +08:00
Xinrea
696cb5f48c feat: archive title 2024-10-09 18:17:35 +08:00
Xinrea
e13b4f5377 fix: disk usage 2024-10-09 17:53:14 +08:00
Xinrea
4b0bb7f31a feat: show version on main window title 2024-10-09 17:28:11 +08:00
Xinrea
1f6badb81d chore: remove automatic page for now 2024-10-09 17:07:02 +08:00
Xinrea
65acc6aa39 feat: show disk usage in summary page 2024-10-09 16:48:22 +08:00
Xinrea
44c41ed99f fix: open archive player window 2024-10-09 16:33:41 +08:00
Xinrea
301eb4f1dc feat: update to tauri 2 release version 2024-10-09 15:00:28 +08:00
Xinrea
5aa7de8d86 feat: cache size statistics 2024-10-09 14:35:25 +08:00
Xinrea
28c03918aa feat: separate clip button 2024-09-28 02:44:56 +08:00
Xinrea
d14b267305 fix: live window on macOS 2024-09-27 12:59:57 +08:00
Xinrea
f2f5d36090 feat: add macOS support 2024-09-27 12:49:48 +08:00
Xinrea
aec8450c53 feat: introduce message system
close #9
2024-09-27 03:07:48 +08:00
Xinrea
efc46341ae feat: multiple accounts support
close #6
2024-09-27 00:46:43 +08:00
Xinrea
3a69eb4864 doc: update readme 2024-09-26 02:42:14 +08:00
Xinrea
7044c2bd83 feat: cover text-adding support
simple cover editing & preview, close #7
2024-09-26 02:30:37 +08:00
Xinrea
74af439deb chore: basic database 2024-09-25 03:44:34 +08:00
Xinrea
faf7096743 feat: support index redirect & using different ways to generate clip 2024-09-25 03:43:53 +08:00
Xinrea
6b3f99c8a7 fix: clip range seek time & hotkey triggered when input 2024-09-25 03:42:44 +08:00
Xinrea
23fd528540 feat: separate clip and post 2024-09-25 03:41:57 +08:00
Xinrea
c701ee002e chore: error handling 2024-09-23 16:26:07 +08:00
Xinrea
238a755698 introduce sqlite 2024-09-22 20:53:36 +08:00
Xinrea
9cc95fb4c8 fix: render error when recorder list empty 2024-09-20 15:01:53 +08:00
Xinrea
bc5887eab4 fix: player slider thumb style 2024-09-20 15:01:10 +08:00
Xinrea
01335ad0ca WIP: update 2024-09-14 18:39:08 +08:00
Xinrea
18f7bd2bf7 WIP: old cache restore 2024-09-13 03:20:46 +08:00
Xinrea
d812deccb8 WIP: update 2024-09-13 01:40:34 +08:00
Xinrea
c7e5cbf26e WIP: using flowbite-svelte 2024-09-09 18:46:52 +08:00
Xinrea
e230ef0b41 WIP: introduce clip_range 2024-09-09 05:03:30 +08:00
Xinrea
5f547e593a WIP: DVR implement 2024-09-09 02:46:42 +08:00
Xinrea
a1c6caece1 ui: adjust styles 2024-09-01 19:53:23 +08:00
Xinrea
0281c2df1c chore: fix app description 2024-09-01 13:04:14 +08:00
83 changed files with 25286 additions and 2396 deletions

View File

@@ -1,57 +1,64 @@
name: Release
on:
workflow_dispatch:
push:
tags:
- 'v*'
workflow_dispatch:
- "v*"
jobs:
release:
publish-tauri:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
platform: [macos-latest, ubuntu-20.04, windows-latest]
include:
- platform: "macos-latest" # for Arm based macs (M1 and above).
args: "--target aarch64-apple-darwin"
- platform: "macos-latest" # for Intel based macs.
args: "--target x86_64-apple-darwin"
- platform: "ubuntu-22.04"
args: ""
- platform: "windows-latest"
args: ""
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
# You can remove libayatana-appindicator3-dev if you don't use the system tray feature.
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Rust setup
uses: dtolnay/rust-toolchain@stable
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "yarn" # Set this to npm, yarn or pnpm.
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable # Set this to dtolnay/rust-toolchain@nightly
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
workspaces: "./src-tauri -> target"
- name: Sync node version and setup cache
uses: actions/setup-node@v3
with:
node-version: 'lts/*'
cache: 'yarn' # Set this to npm, yarn or pnpm.
- name: Install frontend dependencies
- name: install frontend dependencies
# If you don't have `beforeBuildCommand` configured you may want to build your frontend here too.
run: yarn install # Change this to npm, yarn or pnpm.
- name: Build the app
uses: tauri-apps/tauri-action@v0
run: yarn install # change this to npm or pnpm depending on which one you use.
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }} # This only works if your workflow triggers on new tags.
releaseName: 'Bilibili ShadowReplay v__VERSION__' # tauri-action replaces \_\_VERSION\_\_ with the app version.
releaseBody: 'See the assets to download and install this version.'
tagName: v__VERSION__
releaseName: "BiliBili ShadowReplay v__VERSION__"
releaseBody: "See the assets to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}

7
.helix/languages.toml Normal file
View File

@@ -0,0 +1,7 @@
[[language]]
name = "rust"
auto-format = true
[[language]]
name = "svelte"
auto-format = true

2
.ignore Normal file
View File

@@ -0,0 +1,2 @@
*.png
*.svg

View File

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

BIN
doc/accounts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

BIN
doc/archives.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

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

BIN
doc/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
doc/livewindow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

BIN
doc/messages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

BIN
doc/rooms.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

BIN
doc/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

BIN
doc/summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

View File

@@ -1,13 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BiliBili ShadowReplay</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>

23
live_index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="shaka-player/controls.min.css" />
<link rel="stylesheet" href="shaka-player/youtube-theme.css" />
<script src="shaka-player/shaka-player.compiled.min.js"></script>
<script src="shaka-player/shaka-player.ui.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="src/live_main.ts"></script>
<style>
input[type="range"]::-webkit-slider-thumb {
width: 12px; /* 设置滑块按钮宽度 */
height: 12px; /* 设置滑块按钮高度 */
border-radius: 50%; /* 设置为圆形 */
}
</style>
</body>
</html>

View File

@@ -1,7 +1,7 @@
{
"name": "bili-shadowreplay",
"private": true,
"version": "0.1.0",
"version": "1.0.6",
"type": "module",
"scripts": {
"dev": "vite",
@@ -11,16 +11,27 @@
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^1.2.0",
"@tauri-apps/api": "2.0.0-rc.0",
"@tauri-apps/plugin-dialog": "^2.0.0-rc.1",
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
"@tauri-apps/plugin-http": "^2.0.0-rc.2",
"@tauri-apps/plugin-notification": "~2",
"@tauri-apps/plugin-os": "^2.0.0-rc",
"@tauri-apps/plugin-shell": "^2.0.0-rc.1",
"@tauri-apps/plugin-sql": "^2.0.0-rc.1",
"html2canvas": "^1.4.1",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.0.0",
"@tauri-apps/cli": "^1.2.2",
"@tauri-apps/cli": "^2.0.2",
"@tsconfig/svelte": "^3.0.0",
"@types/node": "^18.7.10",
"@types/qrcode": "^1.5.5",
"autoprefixer": "^10.4.14",
"daisyui": "^2.51.5",
"flowbite": "^2.5.1",
"flowbite-svelte": "^0.46.16",
"flowbite-svelte-icons": "^1.6.1",
"postcss": "^8.4.21",
"svelte": "^3.54.0",
"svelte-check": "^3.0.0",

53
public/shaka-player/controls.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,286 @@
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/roboto/v27/KFOmCnqEu92Fr1Me5Q.ttf) format('truetype');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/roboto/v27/KFOlCnqEu92Fr1MmEU9vAw.ttf) format('truetype');
}
.youtube-theme {
font-family: 'Roboto', sans-serif;
}
.youtube-theme .shaka-bottom-controls {
width: 100%;
padding: 0;
padding-bottom: 0;
z-index: 1;
}
.youtube-theme .shaka-bottom-controls {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
.youtube-theme .shaka-ad-controls {
-webkit-box-ordinal-group: 2;
-ms-flex-order: 1;
order: 1;
}
.youtube-theme .shaka-controls-button-panel {
-webkit-box-ordinal-group: 3;
-ms-flex-order: 2;
order: 2;
height: 40px;
padding: 0 10px;
}
.youtube-theme .shaka-range-container {
margin: 4px 10px 4px 10px;
top: 0;
}
.youtube-theme .shaka-small-play-button {
-webkit-box-ordinal-group: -2;
-ms-flex-order: -3;
order: -3;
}
.youtube-theme .shaka-mute-button {
-webkit-box-ordinal-group: -1;
-ms-flex-order: -2;
order: -2;
}
.youtube-theme .shaka-controls-button-panel > * {
margin: 0;
padding: 3px 8px;
color: #EEE;
height: 40px;
}
.youtube-theme .shaka-controls-button-panel > *:focus {
outline: none;
-webkit-box-shadow: inset 0 0 0 2px rgba(27, 127, 204, 0.8);
box-shadow: inset 0 0 0 2px rgba(27, 127, 204, 0.8);
color: #FFF;
}
.youtube-theme .shaka-controls-button-panel > *:hover {
color: #FFF;
}
.youtube-theme .shaka-controls-button-panel .shaka-volume-bar-container {
position: relative;
z-index: 10;
left: -1px;
-webkit-box-ordinal-group: 0;
-ms-flex-order: -1;
order: -1;
opacity: 0;
width: 0px;
-webkit-transition: width 0.2s cubic-bezier(0.4, 0, 1, 1);
height: 3px;
transition: width 0.2s cubic-bezier(0.4, 0, 1, 1);
padding: 0;
}
.youtube-theme .shaka-controls-button-panel .shaka-volume-bar-container:hover,
.youtube-theme .shaka-controls-button-panel .shaka-volume-bar-container:focus {
display: block;
width: 50px;
opacity: 1;
padding: 0 6px;
}
.youtube-theme .shaka-mute-button:hover + div {
opacity: 1;
width: 50px;
padding: 0 6px;
}
.youtube-theme .shaka-current-time {
padding: 0 10px;
font-size: 12px;
}
.youtube-theme .shaka-seek-bar-container {
height: 3px;
position: relative;
top: -1px;
border-radius: 0;
margin-bottom: 0;
}
.youtube-theme .shaka-seek-bar-container .shaka-range-element {
opacity: 0;
}
.youtube-theme .shaka-seek-bar-container:hover {
height: 5px;
top: 0;
cursor: pointer;
}
.youtube-theme .shaka-seek-bar-container:hover .shaka-range-element {
opacity: 1;
cursor: pointer;
}
.youtube-theme .shaka-seek-bar-container input[type=range]::-webkit-slider-thumb {
background: #FF0000;
cursor: pointer;
}
.youtube-theme .shaka-seek-bar-container input[type=range]::-moz-range-thumb {
background: #FF0000;
cursor: pointer;
}
.youtube-theme .shaka-seek-bar-container input[type=range]::-ms-thumb {
background: #FF0000;
cursor: pointer;
}
.youtube-theme .shaka-video-container * {
font-family: 'Roboto', sans-serif;
}
.youtube-theme .shaka-video-container .material-icons-round {
font-family: 'Material Icons Sharp';
}
.youtube-theme .shaka-overflow-menu,
.youtube-theme .shaka-settings-menu {
border-radius: 2px;
background: rgba(28, 28, 28, 0.9);
text-shadow: 0 0 2px rgb(0 0 0%);
-webkit-transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1);
transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1);
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
right: 10px;
bottom: 50px;
padding: 8px 0;
min-width: 200px;
}
.youtube-theme .shaka-settings-menu {
padding: 0 0 8px 0;
}
.youtube-theme .shaka-settings-menu button {
font-size: 12px;
}
.youtube-theme .shaka-settings-menu button span {
margin-left: 33px;
font-size: 13px;
}
.youtube-theme .shaka-settings-menu button[aria-selected="true"] {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
}
.youtube-theme .shaka-settings-menu button[aria-selected="true"] span {
-webkit-box-ordinal-group: 3;
-ms-flex-order: 2;
order: 2;
margin-left: 0;
}
.youtube-theme .shaka-settings-menu button[aria-selected="true"] i {
-webkit-box-ordinal-group: 2;
-ms-flex-order: 1;
order: 1;
font-size: 18px;
padding-left: 5px;
}
.youtube-theme .shaka-overflow-menu button {
padding: 0;
}
.youtube-theme .shaka-overflow-menu button i {
display: none;
}
.youtube-theme .shaka-overflow-menu button .shaka-overflow-button-label {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
cursor: default;
outline: none;
height: 40px;
-webkit-box-flex: 0;
-ms-flex: 0 0 100%;
flex: 0 0 100%;
}
.youtube-theme .shaka-overflow-menu button .shaka-overflow-button-label span {
-ms-flex-negative: initial;
flex-shrink: initial;
padding-left: 15px;
font-size: 13px;
font-weight: 500;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.youtube-theme .shaka-overflow-menu span + span {
color: #FFF;
font-weight: 400 !important;
font-size: 12px !important;
padding-right: 8px;
padding-left: 0 !important;
}
.youtube-theme .shaka-overflow-menu span + span:after {
content: "navigate_next";
font-family: 'Material Icons Sharp';
font-size: 20px;
}
.youtube-theme .shaka-overflow-menu .shaka-pip-button span + span {
padding-right: 15px !important;
}
.youtube-theme .shaka-overflow-menu .shaka-pip-button span + span:after {
content: "";
}
.youtube-theme .shaka-back-to-overflow-button {
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
font-size: 12px;
color: #eee;
height: 40px;
}
.youtube-theme .shaka-back-to-overflow-button .material-icons-round {
font-size: 15px;
padding-right: 10px;
}
.youtube-theme .shaka-back-to-overflow-button span {
margin-left: 3px !important;
}
.youtube-theme .shaka-overflow-menu button:hover,
.youtube-theme .shaka-settings-menu button:hover {
background-color: rgba(255, 255, 255, 0.1);
cursor: pointer;
}
.youtube-theme .shaka-overflow-menu button:hover label,
.youtube-theme .shaka-settings-menu button:hover label {
cursor: pointer;
}
.youtube-theme .shaka-overflow-menu button:focus,
.youtube-theme .shaka-settings-menu button:focus {
background-color: rgba(255, 255, 255, 0.1);
border: none;
outline: none;
}
.youtube-theme .shaka-overflow-menu button,
.youtube-theme .shaka-settings-menu button {
color: #EEE;
}
.youtube-theme .shaka-captions-off {
color: #BFBFBF;
}
.youtube-theme .shaka-overflow-menu-button {
font-size: 18px;
margin-right: 5px;
}
.youtube-theme .shaka-fullscreen-button:hover {
font-size: 25px;
-webkit-transition: font-size 0.1s cubic-bezier(0, 0, 0.2, 1);
transition: font-size 0.1s cubic-bezier(0, 0, 0.2, 1);
}

3834
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package]
name = "bili-shadowreplay"
version = "0.0.3"
description = "A Tauri App"
version = "1.0.0"
description = "BiliBili ShadowReplay"
authors = ["Xinrea"]
license = ""
repository = ""
@@ -10,31 +10,48 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.5.4", features = [] }
tauri-build = { version = "2.0.1", features = [] }
[dependencies]
tauri = { version = "1.7.0", features = ["dialog-all", "fs-all", "http-all", "protocol-asset", "shell-open", "system-tray"] }
tauri = { version = "2.0.1", features = ["protocol-asset", "tray-icon"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["blocking", "json"] }
serde_derive = "1.0.158"
serde = "1.0.158"
sysinfo = "0.32.0"
m3u8-rs = "5.0.3"
async-std = "1.12.0"
futures = "0.3.28"
ffmpeg-sidecar = "1.1"
sqlite = "0.30.4"
chrono = "0.4.24"
ffmpeg-sidecar = "1.2.0"
chrono = { version = "0.4.24", features = ["serde"] }
toml = "0.7.3"
custom_error = "1.9.2"
felgens = { git = "https://github.com/Xinrea/felgens.git", branch = "master" }
felgens = { git = "https://github.com/Xinrea/felgens.git", tag = "v0.4.1" }
regex = "1.7.3"
tokio = "1.27.0"
platform-dirs = "0.3.0"
pct-str = "1.2.0"
md5 = "0.7.0"
notify-rust = "4.8.0"
hyper = { version = "0.14", features = ["full"] }
dashmap = "6.1.0"
urlencoding = "2.1.3"
log = "0.4.22"
simplelog = "0.12.2"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
tauri-plugin-dialog = "2.0.1"
tauri-plugin-shell = "2.0.1"
tauri-plugin-fs = "2.0.1"
tauri-plugin-http = "2.0.1"
tauri-utils = "2.0.1"
tauri-plugin-sql = { version = "2.0.1", features = ["sqlite"] }
tauri-plugin-os = "2.0.1"
tauri-plugin-notification = "2"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2.0.1"

View File

@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build()
}

View File

@@ -0,0 +1,62 @@
{
"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"
]
}

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 150 KiB

440
src-tauri/src/db.rs Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,128 +1,221 @@
pub mod bilibili;
use bilibili::errors::BiliClientError;
use bilibili::BiliClient;
use async_std::{fs, stream::StreamExt};
use bilibili::{errors::BiliClientError, RoomInfo};
use bilibili::{BiliClient, UserInfo};
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 notify_rust::Notification;
use regex::Regex;
use tauri_plugin_notification::NotificationExt;
use std::sync::Arc;
use std::thread;
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc::{self, UnboundedReceiver};
use tokio::sync::{Mutex, RwLock};
use crate::db::{AccountRow, Database, DatabaseError, RecordRow};
use crate::Config;
#[derive(Clone)]
pub struct TsEntry {
pub url: String,
pub sequence: u64,
pub length: f64,
pub _length: f64,
pub size: u64,
}
/// A recorder for BiliBili live streams
///
/// This recorder fetches, caches and serves TS entries, currently supporting only StreamType::FMP4.
/// As high-quality streams are accessible only to logged-in users, the use of a BiliClient, which manages cookies, is required.
// TODO implement StreamType::TS
#[derive(Clone)]
pub struct BiliRecorder {
app_handle: AppHandle,
client: Arc<RwLock<BiliClient>>,
db: Arc<Database>,
account: AccountRow,
config: Arc<RwLock<Config>>,
pub room_id: u64,
pub room_title: String,
pub room_cover: String,
pub room_keyframe: String,
pub user_id: u64,
pub user_name: String,
pub user_sign: String,
pub user_avatar: String,
pub room_info: Arc<RwLock<RoomInfo>>,
pub user_info: Arc<RwLock<UserInfo>>,
pub m3u8_url: Arc<RwLock<String>>,
pub live_status: Arc<RwLock<bool>>,
pub latest_sequence: Arc<Mutex<u64>>,
pub last_sequence: Arc<RwLock<u64>>,
pub ts_length: Arc<RwLock<f64>>,
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)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum StreamType {
TS,
FMP4,
}
custom_error! {pub RecorderError
NotStarted = "Room is offline",
EmptyCache = "Cache is empty",
M3u8ParseFailed = "Parse m3u8 content failed",
InvalidM3u8Url {url: String} = "Invalid m3u8 url: {url}",
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 {
fn from(value: DatabaseError) -> Self {
RecorderError::InvalidDBOP { err: value }
}
}
impl From<BiliClientError> for RecorderError {
fn from(value: BiliClientError) -> Self {
RecorderError::ClientError { err: value }
}
}
impl BiliRecorder {
pub async fn new(room_id: u64, config: Arc<RwLock<Config>>) -> Result<Self, BiliClientError> {
let mut client = BiliClient::new()?;
client.set_cookies(&config.read().await.cookies);
let room_info = client.get_room_info(room_id).await?;
let user_info = client.get_user_info(room_info.user_id).await?;
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(room_info.room_id).await {
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;
}
}
Ok(Self {
let recorder = Self {
app_handle,
client: Arc::new(RwLock::new(client)),
db: db.clone(),
account: account.clone(),
config,
room_id,
room_title: room_info.room_title,
room_cover: room_info.room_cover_url,
room_keyframe: room_info.room_keyframe_url,
user_id: room_info.user_id,
user_name: user_info.user_name,
user_sign: user_info.user_sign,
user_avatar: user_info.user_avatar_url,
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)),
latest_sequence: Arc::new(Mutex::new(0)),
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)),
})
}
pub async fn update_cookies(&mut self, cookies: &str) {
self.client.write().await.set_cookies(cookies);
cache_size: Arc::new(RwLock::new(0)),
};
log::info!("Recorder for room {} created.", room_id);
Ok(recorder)
}
pub async fn reset(&self) {
*self.latest_sequence.lock().await = 0;
*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.room_id).await {
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;
// Live status changed from offline to online, reset recorder and then update m3u8 url and stream type.
self.reset().await;
if let Ok((index_url, stream_type)) = self
.client
.read()
.await
.get_play_url(room_info.room_id)
.await
{
self.m3u8_url.write().await.replace_range(.., &index_url);
*self.stream_type.write().await = stream_type;
// 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 = false;
false
*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);
}
}
}
@@ -136,15 +229,18 @@ impl BiliRecorder {
// Live status is ok, start recording.
while !*self_clone.quit.lock().await {
if let Err(e) = self_clone.update_entries().await {
println!("update entries error: {}", e);
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));
}
println!("recording thread {} quit.", self_clone.room_id);
log::info!("recording thread {} quit.", self_clone.room_id);
});
});
// Thread for danmaku
@@ -159,11 +255,11 @@ impl BiliRecorder {
async fn danmu(&self) {
let (tx, rx) = mpsc::unbounded_channel();
let cookies = self.config.read().await.cookies.clone();
let uid = self.config.read().await.uid.parse().unwrap();
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} {
println!("{}", e);
log::debug!("{}", e);
}
}
@@ -174,44 +270,15 @@ impl BiliRecorder {
) -> Result<(), FelgensError> {
while let Some(msg) = rx.recv().await {
if let WsStreamMessageType::DanmuMsg(msg) = msg {
if self.config.read().await.admin_uid.contains(&msg.uid) {
let content: String = msg.msg;
if content.starts_with("/clip") {
let mut duration = 60.0;
if content.len() > 5 {
let num_part = content.strip_prefix("/clip ").unwrap_or("60");
duration = num_part.parse::<u64>().unwrap_or(60) as f64;
}
if let Err(e) = self.clip(room, duration).await {
if let Err(e) = Notification::new()
.summary("BiliBili ShadowReplay")
.body(format!("生成切片失败: {} - {}s", room, duration).as_str())
.icon("bili-shadowreplay")
.show()
{
println!("notification error: {}", e);
}
println!("clip error: {}", e);
} else if let Err(e) = Notification::new()
.summary("BiliBili ShadowReplay")
.body(format!("生成切片成功: {} - {}s", room, duration).as_str())
.icon("bili-shadowreplay")
.show()
{
println!("notification error: {}", e);
}
}
}
self.app_handle
.emit(&format!("danmu:{}", room), msg.msg.clone())
.unwrap();
}
}
Ok(())
}
pub async fn stop(&self) {
*self.quit.lock().await = false;
}
async fn get_playlist(&self) -> Result<Playlist, BiliClientError> {
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") {
@@ -219,162 +286,158 @@ impl BiliRecorder {
if self.check_status().await {
index_content = self.client.read().await.get_index_content(&url).await?;
} else {
return Err(BiliClientError::InvalidResponse);
return Err(RecorderError::NotStarted);
}
}
m3u8_rs::parse_playlist_res(index_content.as_bytes())
.map_err(|_| BiliClientError::InvalidPlaylist)
.map_err(|_| RecorderError::M3u8ParseFailed)
}
async fn get_header_url(&self) -> Result<String, BiliClientError> {
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(BiliClientError::InvalidResponse);
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)
}
// {
// "format_name": "ts",
// "codec": [
// {
// "codec_name": "avc",
// "current_qn": 10000,
// "accept_qn": [
// 10000,
// 400,
// 250,
// 150
// ],
// "base_url": "/live-bvc/738905/live_51628309_47731828_bluray.m3u8?",
// "url_info": [
// {
// "host": "https://cn-jsyz-ct-03-51.bilivideo.com",
// "extra": "expires=1680532720&len=0&oi=3664564898&pt=h5&qn=10000&trid=100352dbcd4ec5494d6083d4a9a3d9f91aa7&sigparams=cdn,expires,len,oi,pt,qn,trid&cdn=cn-gotcha01&sign=829e59d93ef9ffff8e2aa3bb090f1280&sk=4207df3de646838b084f14f252be3aff94df00e145e0110c92421700c186a851&p2p_type=0&sl=6&free_type=0&mid=475210&sid=cn-jsyz-ct-03-51&chash=1&sche=ban&score=13&pp=rtmp&source=onetier&trace=a0c&site=c66c7195b197c2cf30e5715dbf2922b8&order=1",
// "stream_ttl": 3600
// }
// ],
// "hdr_qn": null,
// "dolby_type": 0,
// "attr_name": ""
// }
// ]
// }
// {
// "format_name": "fmp4",
// "codec": [
// {
// "codec_name": "avc",
// "current_qn": 10000,
// "accept_qn": [
// 10000,
// 400,
// 250,
// 150
// ],
// "base_url": "/live-bvc/738905/live_51628309_47731828_bluray/index.m3u8?",
// "url_info": [
// {
// "host": "https://cn-jsyz-ct-03-51.bilivideo.com",
// "extra": "expires=1680532720&len=0&oi=3664564898&pt=h5&qn=10000&trid=100752dbcd4ec5494d6083d4a9a3d9f91aa7&sigparams=cdn,expires,len,oi,pt,qn,trid&cdn=cn-gotcha01&sign=3d0930160c5870021ebbb457e4630fcf&sk=5bf07b9bbe6df2e0a6bc476fe3d9a642c8e387f5b7e5df7fa9e1b9d0abc8bd13&flvsk=4207df3de646838b084f14f252be3aff94df00e145e0110c92421700c186a851&p2p_type=0&sl=6&free_type=0&mid=475210&sid=cn-jsyz-ct-03-51&chash=1&sche=ban&bvchls=1&score=13&pp=rtmp&source=onetier&trace=a0c&site=c66c7195b197c2cf30e5715dbf2922b8&order=1",
// "stream_ttl": 3600
// },
// {
// "host": "https://d1--cn-gotcha208.bilivideo.com",
// "extra": "expires=1680532720&len=0&oi=3664564898&pt=h5&qn=10000&trid=100752dbcd4ec5494d6083d4a9a3d9f91aa7&sigparams=cdn,expires,len,oi,pt,qn,trid&cdn=cn-gotcha208&sign=b63815ac70b18420c64a661465f92962&sk=5bf07b9bbe6df2e0a6bc476fe3d9a642c8e387f5b7e5df7fa9e1b9d0abc8bd13&p2p_type=0&sl=6&free_type=0&mid=475210&pp=rtmp&source=onetier&trace=4&site=c66c7195b197c2cf30e5715dbf2922b8&order=2",
// "stream_ttl": 3600
// }
// ],
// "hdr_qn": null,
// "dolby_type": 0,
// "attr_name": ""
// }
// ]
// }
async fn ts_url(&self, ts_url: &String) -> Result<String, BiliClientError> {
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 => {
// Get host from m3u8 url
let url = self.m3u8_url.read().await.clone();
if let Some(host_part) = url.strip_prefix("https://") {
if let Some(host) = host_part.split('/').next() {
Ok(format!("https://{}/{}", host, ts_url))
} else {
Err(BiliClientError::InvalidUrl)
}
if let Some(pos) = url.rfind("index.m3u8") {
Ok(format!("{}{}", &url[..pos], ts_url))
} else {
Err(BiliClientError::InvalidUrl)
Err(RecorderError::InvalidM3u8Url { url })
}
}
StreamType::FMP4 => {
let url = self.m3u8_url.read().await.clone();
if let Some(prefix_part) = url.strip_suffix("index.m3u8") {
Ok(format!("{}{}", prefix_part, ts_url))
if let Some(pos) = url.rfind("index.m3u8") {
Ok(format!("{}{}", &url[..pos], ts_url))
} else {
Err(BiliClientError::InvalidUrl)
Err(RecorderError::InvalidM3u8Url { url })
}
}
}
}
async fn update_entries(&self) -> Result<(), BiliClientError> {
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(BiliClientError::InvalidPlaylist);
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 header = TsEntry {
let mut header = TsEntry {
url: full_header_url.clone(),
sequence: 0,
length: 0.0,
_length: 0.0,
size: 0,
};
let file_name = header_url.split('/').last().unwrap();
// Download header
if let Err(e) = self
match self
.client
.read()
.await
.download_ts(
&self.config.read().await.cache,
self.room_id,
&full_header_url,
)
.download_ts(&full_header_url, &format!("{}/{}", work_dir, file_name))
.await
{
println!("Error downloading header: {:?}", e);
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);
}
}
*self.header.write().await = Some(header);
}
match parsed {
Ok(Playlist::MasterPlaylist(pl)) => println!("Master playlist:\n{:?}", pl),
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.latest_sequence.lock().await {
if sequence <= *self.last_sequence.read().await {
sequence += 1;
continue;
}
let mut ts_entry = TsEntry {
url: ts.uri,
sequence,
length: ts.duration as f64,
_length: ts.duration as f64,
size: 0,
};
let client = self.client.clone();
let ts_url = self.ts_url(&ts_entry.url).await?;
@@ -382,92 +445,132 @@ impl BiliRecorder {
if ts_url.is_empty() {
continue;
}
let room_id = self.room_id;
let config = self.config.clone();
let work_dir = work_dir.clone();
let cache_size_clone = self.cache_size.clone();
handles.push(tokio::task::spawn(async move {
if let Err(e) = client
let ts_url_clone = ts_url.clone();
let file_name = ts_url_clone.split('/').last().unwrap();
match client
.read()
.await
.download_ts(&config.read().await.cache, room_id, &ts_url)
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name))
.await
{
println!("download ts failed: {}", e);
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.latest_sequence.lock().await = sequence;
*self.last_sequence.write().await = sequence;
let mut total_length = self.ts_length.write().await;
*total_length += ts.duration as f64;
while *total_length > self.config.read().await.max_len as f64 {
*total_length -= entries[0].length;
if let Err(e) = std::fs::remove_file(
BiliClient::url_to_file_name(
&self.config.read().await.cache,
room_id,
&entries[0].url,
)
.1,
) {
println!("remove file failed: {}", e);
}
entries.remove(0);
}
sequence += 1;
}
join_all(handles).await.into_iter().for_each(|e| {
if let Err(e) = e {
println!("download ts failed: {:?}", 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(BiliClientError::InvalidIndex);
return Err(RecorderError::InvalidPlaylist);
}
}
Ok(())
}
pub async fn clip(&self, room_id: u64, d: f64) -> Result<String, BiliClientError> {
let mut duration = d;
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(BiliClientError::EmptyCache);
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;
}
for e in entry_copy.iter().rev() {
let length = e.length;
to_combine.push(e);
if duration <= length {
break;
}
duration -= length;
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
}
to_combine.reverse();
if *self.stream_type.read().await == StreamType::FMP4 {
// add header to vec
let header = header_copy.as_ref().unwrap();
to_combine.insert(0, header);
}
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();
for e in to_combine {
file_list +=
&BiliClient::url_to_file_name(&self.config.read().await.cache, room_id, &e.url).1;
file_list += "|";
// 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;
}
}
let output_path = self.config.read().await.output.clone();
std::fs::create_dir_all(&output_path).expect("create clips folder failed");
std::fs::create_dir_all(output_path).expect("create clips folder failed");
let file_name = format!(
"{}/[{}]{}_({})_{}.mp4",
"{}/[{}]{}_{}_{:.1}.mp4",
output_path,
self.room_id,
self.room_title,
Utc::now().format("%Y-%m-%d-%H-%M-%S"),
d
ts,
Utc::now().format("%m%d%H%M%S"),
y - x
);
println!("{}", file_name);
let args = format!("-i concat:{} -c copy", file_list);
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())
@@ -476,10 +579,200 @@ impl BiliRecorder {
.iter()
.unwrap()
.for_each(|e| match e {
FfmpegEvent::Log(LogLevel::Error, e) => println!("Error: {}", e),
FfmpegEvent::Progress(p) => println!("Progress: {}", p.time),
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 < start {
offset += 1.0;
continue;
}
to_combine.push(e);
if offset >= 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
}
}

View File

@@ -1,17 +1,29 @@
pub mod errors;
pub mod profile;
pub mod response;
use crate::db::AccountRow;
use super::StreamType;
use errors::BiliClientError;
use pct_str::PctString;
use pct_str::URIReserved;
use profile::Profile;
use regex::Regex;
use reqwest::Client;
use response::GeneralResponse;
use response::PostVideoMetaResponse;
use response::PreuploadResponse;
use response::VideoSubmitData;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use serde_json::Value;
use std::sync::Mutex;
use std::path::Path;
use std::time::SystemTime;
use super::StreamType;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use tokio::sync::RwLock;
use tokio::time::Instant;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -145,23 +157,24 @@ pub struct P2pData {
pub m_servers: Value,
}
/// BiliClient is thread safe
pub struct BiliClient {
client: Client,
headers: reqwest::header::HeaderMap,
extra: Mutex<String>,
extra: RwLock<String>,
}
#[derive(Debug)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RoomInfo {
pub room_id: u64,
pub room_title: String,
pub room_cover_url: String,
pub room_keyframe_url: String,
pub user_id: u64,
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(Debug)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserInfo {
pub user_id: u64,
pub user_name: String,
@@ -186,26 +199,6 @@ pub struct QrStatus {
impl BiliClient {
pub fn new() -> Result<BiliClient, BiliClientError> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("authority", "api.live.bilibili.com".parse().unwrap());
headers.insert("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7".parse().unwrap());
headers.insert(
"accept-language",
"zh-CN,zh;q=0.9,en;q=0.8".parse().unwrap(),
);
headers.insert("cache-control", "max-age=0".parse().unwrap());
headers.insert(
"sec-ch-ua",
"\"Google Chrome\";v=\"111\", \"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"111\""
.parse()
.unwrap(),
);
headers.insert("sec-ch-ua-mobile", "?0".parse().unwrap());
headers.insert("sec-ch-ua-platform", "\"macOS\"".parse().unwrap());
headers.insert("sec-fetch-dest", "document".parse().unwrap());
headers.insert("sec-fetch-mode", "navigate".parse().unwrap());
headers.insert("sec-fetch-site", "none".parse().unwrap());
headers.insert("sec-fetch-user", "?1".parse().unwrap());
headers.insert("upgrade-insecure-requests", "1".parse().unwrap());
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()
@@ -215,22 +208,33 @@ impl BiliClient {
Ok(BiliClient {
client,
headers,
extra: Mutex::new("".into()),
extra: RwLock::new("".into()),
})
} else {
Err(BiliClientError::InitClientError)
}
}
pub fn set_cookies(&mut self, cookies: &str) {
self.headers.insert(
"cookie",
cookies.parse().expect("parse cookie failed"),
);
}
pub fn logout(&mut self) {
self.headers.remove("cookie");
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> {
@@ -262,8 +266,10 @@ impl BiliClient {
qrcode_key
))
.headers(self.headers.clone())
.send().await?
.json().await?;
.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 {
@@ -277,50 +283,82 @@ impl BiliClient {
Ok(QrStatus { code, cookies })
}
pub async fn get_user_info(&self, user_id: u64) -> Result<UserInfo, BiliClientError> {
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": ""
"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(self.headers.clone())
.send().await?
.json().await?;
.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()
.ok_or(BiliClientError::InvalidValue)?
.to_string(),
user_sign: res["data"]["sign"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string(),
user_avatar_url: res["data"]["face"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string(),
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, room_id: u64) -> Result<RoomInfo, BiliClientError> {
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(self.headers.clone())
.send().await?
.json().await?;
.headers(headers)
.send()
.await?
.json()
.await?;
let code = res["code"].as_u64().ok_or(BiliClientError::InvalidValue)?;
if code != 0 {
return Err(BiliClientError::InvalidCode);
@@ -357,14 +395,20 @@ impl BiliClient {
})
}
pub async fn get_play_url(&self, room_id: u64) -> Result<(String, StreamType), BiliClientError> {
pub async fn get_play_url(
&self,
account: &AccountRow,
room_id: u64,
) -> Result<(String, StreamType), BiliClientError> {
let mut headers = self.headers.clone();
headers.insert("cookie", account.cookies.parse().unwrap());
let res: PlayUrlResponse = 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(self.headers.clone())
.headers(headers)
.send().await?
.json().await?;
if res.code == 0 {
@@ -372,10 +416,12 @@ impl BiliClient {
// Get fmp4 format
if let Some(format) = stream.format.get(1) {
self.get_url_from_format(format)
.await
.ok_or(BiliClientError::InvalidFormat)
.map(|url| (url, StreamType::FMP4))
} else if let Some(format) = stream.format.first() {
self.get_url_from_format(format)
.await
.ok_or(BiliClientError::InvalidFormat)
.map(|url| (url, StreamType::TS))
} else {
@@ -389,14 +435,14 @@ impl BiliClient {
}
}
fn get_url_from_format(&self, format: &Format) -> Option<String> {
async fn get_url_from_format(&self, format: &Format) -> Option<String> {
if let Some(codec) = format.codec.first() {
if let Some(url_info) = codec.url_info.first() {
let base_url = codec.base_url.strip_suffix('?').unwrap();
let extra = "?".to_owned() + &url_info.extra.clone();
let host = url_info.host.clone();
let url = format!("{}{}", host, base_url);
*self.extra.lock().unwrap() = extra;
*self.extra.write().await = extra;
Some(url)
} else {
None
@@ -409,34 +455,28 @@ impl BiliClient {
pub async fn get_index_content(&self, url: &String) -> Result<String, BiliClientError> {
Ok(self
.client
.get(url.to_owned() + self.extra.lock().unwrap().as_str())
.get(url.to_owned() + self.extra.read().await.as_str())
.headers(self.headers.clone())
.send().await?
.text().await?)
.send()
.await?
.text()
.await?)
}
pub async fn download_ts(
&self,
cache_path: &str,
room_id: u64,
url: &str,
) -> Result<(), BiliClientError> {
let (tmp_path, file_name) = Self::url_to_file_name(cache_path, room_id, url);
std::fs::create_dir_all(tmp_path).expect("create tmp_path failed");
let url = url.to_owned() + self.extra.lock().unwrap().as_str();
let res = self.client.get(url).headers(self.headers.clone()).send().await?;
let mut file = std::fs::File::create(file_name).unwrap();
let mut content = std::io::Cursor::new(res.bytes().await?);
std::io::copy(&mut content, &mut file).unwrap();
Ok(())
}
pub fn url_to_file_name(cache_path: &str, room_id: u64, url: &str) -> (String, String) {
let tmp_path = format!("{}/{}/", cache_path, room_id);
let url = reqwest::Url::parse(url).unwrap();
let file_name = url.path_segments().and_then(|x| x.last()).unwrap();
let full_file = tmp_path.clone() + file_name.split('?').collect::<Vec<&str>>()[0];
(tmp_path, full_file)
pub async fn download_ts(&self, url: &str, file_path: &str) -> Result<u64, BiliClientError> {
let url = url.to_owned() + self.extra.read().await.as_str();
let res = self
.client
.get(url)
.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
@@ -450,8 +490,10 @@ impl BiliClient {
.client
.get("https://api.bilibili.com/x/web-interface/nav")
.headers(self.headers.clone())
.send().await?
.json().await?;
.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())
@@ -516,4 +558,278 @@ impl BiliClient {
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> {
let preupload = self.preupload_video(account, video_file).await?;
let metaposted = self.post_video_meta(&preupload, video_file).await?;
let uploaded = self
.upload_video(&preupload, &metaposted, video_file)
.await?;
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(())
}
}

View File

@@ -5,12 +5,11 @@ custom_error! {pub BiliClientError
InitClientError = "Client init error",
InvalidCode = "Invalid Code",
InvalidValue = "Invalid value",
InvalidIndex = "Invalid index",
InvalidPlaylist = "Invalid playlist",
InvalidUrl = "Invalid url",
InvalidFormat = "Invalid stream format",
EmptyCache = "Empty cache",
ClientError{err: reqwest::Error} = "Client error",
IOError{err: std::io::Error} = "IO error",
}
impl From<reqwest::Error> for BiliClientError {
@@ -18,3 +17,15 @@ impl From<reqwest::Error> for BiliClientError {
BiliClientError::ClientError { err: e }
}
}
impl From<std::io::Error> for BiliClientError {
fn from(e: std::io::Error) -> Self {
BiliClientError::IOError { err: e }
}
}
impl From<BiliClientError> for String {
fn from(value: BiliClientError) -> Self {
value.to_string()
}
}

View File

@@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Profile {
pub videos: Vec<Video>,
pub cover: String,
pub cover43: Option<String>,
pub title: String,
// 1 自制2 转载
pub copyright: u8,
pub tid: u64,
pub tag: String,
pub desc_format_id: u64,
pub desc: String,
pub recreate: i8,
pub dynamic: String,
pub interactive: u8,
pub act_reserve_create: u8,
pub no_disturbance: u8,
pub no_reprint: u8,
pub subtitle: Subtitle,
pub dolby: u8,
pub lossless_music: u8,
pub up_selection_reply: bool,
pub up_close_reply: bool,
pub up_close_danmu: bool,
pub web_os: u8,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Subtitle {
open: u8,
lan: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Video {
pub title: String,
pub filename: String,
pub desc: String,
pub cid: u64,
}

View File

@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct GeneralResponse {
pub code: u8,
pub message: String,
pub ttl: u8,
pub data: Data,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
pub enum Data {
VideoSubmit(VideoSubmitData),
Cover(CoverData),
}
#[derive(Serialize, Deserialize, Debug)]
pub struct VideoSubmitData {
pub aid: u64,
pub bvid: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CoverData {
pub url: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PreuploadResponse {
pub endpoint: String,
pub upos_uri: String,
pub auth: String,
pub chunk_size: usize,
pub biz_id: u64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PostVideoMetaResponse {
pub bucket: String,
pub key: String,
pub upload_id: String,
}

View File

@@ -0,0 +1,339 @@
use crate::db::{AccountRow, Database, RecordRow};
use crate::recorder::bilibili::UserInfo;
use crate::recorder::RecorderError;
use crate::recorder::{bilibili::RoomInfo, BiliRecorder};
use crate::Config;
use custom_error::custom_error;
use dashmap::DashMap;
use hyper::{
service::{make_service_fn, service_fn},
Body, Request, Response, Server,
};
use std::net::SocketAddr;
use std::{convert::Infallible, sync::Arc};
use tauri::AppHandle;
use tokio::{net::TcpListener, sync::RwLock};
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
pub struct RecorderList {
pub count: usize,
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 {
app_handle: AppHandle,
config: Arc<RwLock<Config>>,
recorders: Arc<DashMap<u64, BiliRecorder>>,
hls_server_addr: Arc<RwLock<Option<SocketAddr>>>,
}
custom_error! {pub RecorderManagerError
AlreadyExisted { room_id: u64 } = "Recorder {room_id} already existed",
NotFound {room_id: u64 } = "Recorder {room_id} not found",
RecorderError { err: RecorderError } = "Recorder error",
IOError {err: std::io::Error } = "IO error",
HLSError { err: hyper::Error } = "HLS server error",
}
impl From<hyper::Error> for RecorderManagerError {
fn from(value: hyper::Error) -> Self {
RecorderManagerError::HLSError { err: value }
}
}
impl From<std::io::Error> for RecorderManagerError {
fn from(value: std::io::Error) -> Self {
RecorderManagerError::IOError { err: value }
}
}
impl From<RecorderError> for RecorderManagerError {
fn from(value: RecorderError) -> Self {
RecorderManagerError::RecorderError { err: value }
}
}
impl From<RecorderManagerError> for String {
fn from(value: RecorderManagerError) -> Self {
value.to_string()
}
}
impl RecorderManager {
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
RecorderManager {
app_handle,
config,
recorders: Arc::new(DashMap::new()),
hls_server_addr: Arc::new(RwLock::new(None)),
}
}
/// starting HLS server
pub async fn run_hls(&self) -> Result<(), RecorderManagerError> {
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
let listener = TcpListener::bind(&addr).await?;
let server_addr = self.start_hls_server(listener).await?;
log::info!("HLS server started on {}", server_addr);
self.hls_server_addr.write().await.replace(server_addr);
Ok(())
}
pub async fn add_recorder(
&self,
webid: &str,
db: &Arc<Database>,
account: &AccountRow,
room_id: u64,
) -> Result<(), RecorderManagerError> {
// check existing recorder
if self.recorders.contains_key(&room_id) {
return Err(RecorderManagerError::AlreadyExisted { room_id });
}
let recorder = BiliRecorder::new(
self.app_handle.clone(),
webid,
db,
room_id,
account,
self.config.clone(),
)
.await?;
self.recorders.insert(room_id, recorder);
// run recorder
let recorder = self.recorders.get(&room_id).unwrap();
recorder.value().run().await;
Ok(())
}
pub async fn remove_recorder(&self, room_id: u64) -> Result<(), RecorderManagerError> {
let recorder = self.recorders.remove(&room_id);
if recorder.is_none() {
return Err(RecorderManagerError::NotFound { room_id });
}
// remove related cache folder
let cache_folder = format!(
"{}/{}",
self.config.read().await.cache,
room_id
);
tokio::fs::remove_dir_all(cache_folder).await?;
Ok(())
}
pub async fn clip(
&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(
&self,
output_path: &str,
room_id: u64,
ts: u64,
start: f64,
end: 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_range(ts, start, end, output_path)
.await?)
}
pub async fn get_recorder_list(&self) -> RecorderList {
let mut summary = RecorderList {
count: self.recorders.len(),
recorders: Vec::new(),
};
for recorder in self.recorders.iter() {
let recorder = recorder.value();
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.sort_by(|a, b| a.room_id.cmp(&b.room_id));
summary
}
pub async fn get_recorder_info(&self, room_id: u64) -> Option<RecorderInfo> {
if let Some(recorder) = self.recorders.get(&room_id) {
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,
};
Some(room_info)
} else {
None
}
}
pub async fn get_archives(&self, room_id: u64) -> Result<Vec<RecordRow>, RecorderManagerError> {
if let Some(recorder) = self.recorders.get(&room_id) {
Ok(recorder.get_archives().await?)
} else {
Err(RecorderManagerError::NotFound { room_id })
}
}
pub async fn get_archive(
&self,
room_id: u64,
live_id: u64,
) -> Result<RecordRow, RecorderManagerError> {
if let Some(recorder) = self.recorders.get(&room_id) {
Ok(recorder.get_archive(live_id).await?)
} else {
Err(RecorderManagerError::NotFound { room_id })
}
}
pub async fn delete_archive(&self, room_id: u64, ts: u64) {
if let Some(recorder) = self.recorders.get(&room_id) {
recorder.delete_archive(ts).await;
}
}
async fn start_hls_server(
&self,
listener: TcpListener,
) -> Result<SocketAddr, RecorderManagerError> {
let recorders = self.recorders.clone();
let config = self.config.clone();
let make_svc = make_service_fn(move |_conn| {
let recorders = recorders.clone();
let config = config.clone();
async move {
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
let recorders = recorders.clone();
let config = config.clone();
async move {
let cache_path = config.read().await.cache.clone();
let path = req.uri().path();
let path_segs: Vec<&str> = path.split('/').collect();
// path_segs should be size 4: /21484828/{timestamp}/playlist.m3u8
if path_segs.len() != 4 {
return Ok::<_, Infallible>(
Response::builder()
.status(400)
.body(Body::from("Request Path Not Found"))
.unwrap(),
);
}
// parse room id
let room_id = path_segs[1].parse::<u64>().unwrap();
let timestamp = path_segs[2].parse::<u64>().unwrap();
// if path is /room_id/{timestamp}/playlist.m3u8
if path_segs[3] == "playlist.m3u8" {
// get recorder
let recorder = recorders.get(&room_id);
if recorder.is_none() {
return Ok::<_, Infallible>(
Response::builder()
.status(404)
.body(Body::from("Recorder Not Found"))
.unwrap(),
);
}
let recorder = recorder.unwrap();
// response with recorder generated m3u8, which contains ts entries that cached in local
let m3u8_content = recorder.value().generate_m3u8(timestamp).await;
Ok::<_, Infallible>(
Response::builder()
.status(200)
.header("Content-Type", "application/vnd.apple.mpegurl")
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "GET, OPTIONS")
.body(Body::from(m3u8_content))
.unwrap(),
)
} else {
// try to find requested ts file in recorder's cache
// cache files are stored in {cache_dir}/{room_id}/{timestamp}/{ts_file}
let ts_file = format!("{}/{}", cache_path, path);
let recorder = recorders.get(&room_id);
if recorder.is_none() {
return Ok::<_, Infallible>(
Response::builder()
.status(404)
.body(Body::from("Recorder Not Found"))
.unwrap(),
);
}
let ts_file_content = tokio::fs::read(ts_file).await;
if ts_file_content.is_err() {
return Ok::<_, Infallible>(
Response::builder()
.status(404)
.body(Body::from("TS File Not Found"))
.unwrap(),
);
}
let ts_file_content = ts_file_content.unwrap();
Ok::<_, Infallible>(
Response::builder()
.status(200)
.header("Content-Type", "video/MP2T")
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "GET, OPTIONS")
.body(Body::from(ts_file_content))
.unwrap(),
)
}
}
}))
}
});
let server = Server::from_tcp(listener.into_std().unwrap())?.serve(make_svc);
let addr = server.local_addr();
tokio::spawn(async move {
if let Err(e) = server.await {
log::error!("HLS server error: {}", e);
}
});
Ok(addr)
}
pub async fn get_hls_server_addr(&self) -> Option<SocketAddr> {
*self.hls_server_addr.read().await
}
}

43
src-tauri/src/tray.rs Normal file
View File

@@ -0,0 +1,43 @@
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, Runtime,
};
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
let hide_i = MenuItem::with_id(app, "hide", "隐藏窗口", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&hide_i, &quit_i])?;
let _ = TrayIconBuilder::with_id("tray")
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id.as_ref() {
"hide" => {
let window = app.get_webview_window("main").unwrap();
window.hide().unwrap();
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app);
Ok(())
}

View File

@@ -2,79 +2,36 @@
"build": {
"beforeDevCommand": "yarn dev",
"beforeBuildCommand": "yarn build",
"devPath": "http://localhost:8054",
"distDir": "../dist",
"withGlobalTauri": false
"frontendDist": "../dist",
"devUrl": "http://localhost:8054"
},
"package": {
"productName": "bili-shadowreplay",
"version": "0.1.0"
"bundle": {
"active": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"targets": "all"
},
"tauri": {
"systemTray": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true
},
"allowlist": {
"all": false,
"shell": {
"all": false,
"open": true
},
"http": {
"all": true,
"request": true,
"scope": [
"https://**",
"http://**"
]
},
"dialog": {
"all": true,
"open": true,
"save": true
},
"protocol": {
"all": false,
"asset": true,
"assetScope": [
"**"
]
},
"fs": {
"all": true,
"scope": [
"**"
]
}
},
"bundle": {
"active": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "cn.vjoi.bilishadowplay",
"targets": "all"
},
"productName": "bili-shadowreplay",
"version": "../package.json",
"identifier": "cn.vjoi.bilishadowreplay",
"plugins": {
"sql": {
"preload": ["sqlite:data.db"]
}
},
"app": {
"withGlobalTauri": false,
"security": {
"assetProtocol": {
"scope": ["**"],
"enable": true
},
"csp": null
},
"updater": {
"active": false
},
"windows": [
{
"fullscreen": false,
"resizable": true,
"title": "BiliBili ShadowReplay",
"width": 800,
"height": 600,
"theme": "Light"
}
]
}
}
}

View File

@@ -0,0 +1,17 @@
{
"app": {
"windows": [
{
"label": "main",
"fullscreen": false,
"resizable": true,
"title": "BiliBili ShadowReplay",
"width": 1300,
"height": 600,
"transparent": false,
"decorations": true,
"theme": "Light"
}
]
}
}

View File

@@ -0,0 +1,17 @@
{
"app": {
"windows": [
{
"label": "main",
"fullscreen": false,
"resizable": true,
"title": "BiliBili ShadowReplay",
"width": 1300,
"height": 600,
"transparent": false,
"decorations": true,
"theme": "Light"
}
]
}
}

View File

@@ -0,0 +1,17 @@
{
"app": {
"windows": [
{
"label": "main",
"fullscreen": false,
"resizable": true,
"title": "BiliBili ShadowReplay",
"width": 1300,
"height": 600,
"transparent": false,
"decorations": false,
"theme": "Light"
}
]
}
}

View File

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

412
src/AppLive.svelte Normal file
View File

@@ -0,0 +1,412 @@
<script lang="ts">
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 TitleBar from "./lib/TitleBar.svelte";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import html2canvas from "html2canvas";
import type { AccountInfo, RecordItem } from "./lib/db";
import { platform } from "@tauri-apps/plugin-os";
import { ClapperboardPlaySolid, PlayOutline } from "flowbite-svelte-icons";
import type { Profile, VideoItem, Config } from "./lib/interface";
import { onMount } from "svelte";
let use_titlebar = platform() == "windows";
const appWindow = getCurrentWebviewWindow();
const urlParams = new URLSearchParams(window.location.search);
const port = urlParams.get("port");
const room_id = parseInt(urlParams.get("room_id"));
const ts = parseInt(urlParams.get("ts"));
// get profile in local storage with a default value
let profile: Profile = get_profile();
let config: Config = null;
invoke("get_config").then((c) => {
config = c as Config;
console.log(config);
});
function get_profile(): Profile {
const profile_str = window.localStorage.getItem("profile-" + room_id);
if (profile_str && profile_str.includes("videos")) {
return JSON.parse(profile_str);
}
return default_profile();
}
function default_profile(): Profile {
return {
videos: [],
cover: "",
cover43: null,
title: "",
copyright: 1,
tid: 27,
tag: "",
desc_format_id: 9999,
desc: "",
recreate: -1,
dynamic: "",
interactive: 0,
act_reserve_create: 0,
no_disturbance: 0,
no_reprint: 0,
subtitle: {
open: 0,
lan: "",
},
dolby: 0,
lossless_music: 0,
up_selection_reply: false,
up_close_danmu: false,
up_close_reply: false,
web_os: 0,
};
}
let archive: RecordItem = null;
let loading = false;
let start = 0.0;
let end = 0.0;
function generateCover() {
const video = document.getElementById("video") as HTMLVideoElement;
var w = video.videoWidth;
var h = video.videoHeight;
var canvas = document.createElement("canvas");
canvas.width = 1280;
canvas.height = 720;
var context = canvas.getContext("2d");
context.drawImage(video, 0, 0, w, h, 0, 0, 1280, 720);
return canvas.toDataURL();
}
let cover_text = "";
let preview = false;
let uid_selected = 0;
let video_selected = 0;
$: video_src = video ? convertFileSrc(config.output + "/" + video.name) : "";
let accounts = [];
let videos = [];
let video = null;
let cover = "";
invoke("get_accounts").then((account_info: AccountInfo) => {
accounts = account_info.accounts.map((a) => {
return {
value: a.uid,
name: a.name,
};
});
console.log(accounts);
});
get_video_list();
invoke("get_archive", { roomId: room_id, liveId: ts }).then(
(a: RecordItem) => {
console.log(a);
archive = a;
appWindow.setTitle(`[${room_id}][${format_ts(ts)}]${archive.title}`);
},
);
function update_title(str: string) {
appWindow.setTitle(
`[${room_id}][${format_ts(ts)}]${archive.title} - ${str}`,
);
}
function format_ts(ts: number) {
const date = new Date(ts * 1000);
return date.toLocaleString();
}
async function get_video_list() {
videos = (
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
).map((v) => {
return {
value: v.id,
name: v.file,
cover: v.cover,
};
});
console.log(videos, video_selected);
}
function find_video(e) {
const id = parseInt(e.target.value);
video = videos.find((v) => {
return v.value == id;
});
cover = video.cover;
console.log("video selected", videos, video, e, id);
}
async function generate_clip() {
if (end == 0) {
alert("请检查选区范围");
return;
}
if (end - start < 5.0) {
alert("选区过短:," + (end - start).toFixed(2));
return;
}
loading = true;
let new_cover = generateCover();
update_title(`切片生成中`);
let new_video = (await invoke("clip_range", {
roomId: room_id,
cover: new_cover,
ts: ts,
x: start,
y: end,
})) as VideoItem;
update_title(`切片生成成功`);
console.log("video file generatd:", video);
await get_video_list();
video_selected = new_video.id;
video = videos.find((v) => {
return v.value == new_video.id;
});
cover = new_video.cover;
loading = false;
}
async function do_post() {
if (!video) {
return;
}
update_title(`投稿上传中`);
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
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
invoke("upload_procedure", {
uid: uid_selected,
roomId: room_id,
videoId: video_selected,
cover: rendered_cover,
profile: profile,
})
.then(async () => {
loading = false;
update_title(`投稿成功`);
video_selected = 0;
await get_video_list();
})
.catch((e) => {
loading = false;
update_title(`投稿失败`);
alert(e);
});
}
async function delete_video() {
if (!video) {
return;
}
loading = true;
update_title(`删除中`);
await invoke("delete_video", { id: video_selected });
update_title(`删除成功`);
loading = false;
video_selected = 0;
video = null;
cover = "";
await get_video_list();
}
// when window resize, update post panel height
onMount(() => {
let post_panel = document.getElementById("post-panel");
if (post_panel) {
post_panel.style.height = `calc(100vh - 35px)`;
}
window.addEventListener("resize", () => {
if (post_panel) {
post_panel.style.height = `calc(100vh - 35px)`;
}
});
});
</script>
<main>
{#if use_titlebar}
<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
class="w-1/4 h-screen overflow-hidden border-solid bg-gray-50 border-l-2 border-slate-200 z-[39]"
>
<div
id="post-panel"
class="mt-6 overflow-y-auto overflow-x-hidden p-6"
class:titlebar={use_titlebar}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if video}
<div
class="w-full mb-2"
on:click={() => {
preview = true;
}}
>
<div id="capture" class="cover-wrap relative cursor-pointer">
<div
class="cover-text absolute py-1 px-8"
class:play-icon={false}
>
{cover_text}
</div>
<div class="play-icon opacity-0">
<PlayOutline class="w-full h-full absolute" color="white" />
</div>
<img src={cover} alt="cover" />
</div>
</div>
{/if}
<div class="w-full flex flex-col justify-center">
<Label>切片列表</Label>
<Select
items={videos}
bind:value={video_selected}
on:change={find_video}
class="mb-2"
/>
<ButtonGroup>
<Button on:click={generate_clip} disabled={loading} color="primary">
{#if loading}
<Spinner class="me-3" size="4" />
{:else}
<ClapperboardPlaySolid />
{/if}
从选区生成新切片</Button
>
<Button
color="red"
disabled={!loading && !video}
on:click={delete_video}>删除</Button
>
</ButtonGroup>
</div>
<Hr />
<Label class="mt-4">标题</Label>
<Input
size="sm"
bind:value={profile.title}
on:change={() => {
window.localStorage.setItem(
"profile-" + room_id,
JSON.stringify(profile),
);
}}
/>
<Label class="mt-2">封面文本</Label>
<Textarea bind:value={cover_text} />
<Label class="mt-2">描述</Label>
<Textarea
bind:value={profile.desc}
on:change={() => {
window.localStorage.setItem(
"profile-" + room_id,
JSON.stringify(profile),
);
}}
/>
<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}
投稿
</Button>
</div>
{/if}
</div>
</div>
</div>
</main>
<style>
main {
width: 100vw;
height: 100vh;
}
.cover-wrap:hover {
opacity: 0.8;
}
.cover-wrap:hover .play-icon {
opacity: 0.5;
}
.cover-text {
white-space: pre-wrap;
font-size: 24px;
line-height: 1.3;
font-weight: bold;
color: rgb(255, 127, 0);
text-shadow:
-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>

41
src/lib/About.svelte Normal file
View File

@@ -0,0 +1,41 @@
<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>

139
src/lib/Account.svelte Normal file
View File

@@ -0,0 +1,139 @@
<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>

152
src/lib/BSidebar.svelte Normal file
View File

@@ -0,0 +1,152 @@
<script>
import {
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
export let activeUrl = "#总览";
export let room_count = 0;
export let message_cnt = 0;
</script>
<Sidebar {activeUrl} asideClass="w-72 h-full z-[40]">
<SidebarWrapper divClass="overflow-y-auto py-4 px-3 bg-gray-50 h-full">
<SidebarGroup>
<SidebarItem
label="总览"
href="#"
on:click={() => {
activeUrl = "#总览";
}}
>
<svelte:fragment slot="icon">
<ChartPieSolid
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 = "#直播间";
}}
{spanClass}
>
<svelte:fragment slot="icon">
<GridSolid
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>
<svelte:fragment slot="subtext">
<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}
</span>
</svelte:fragment>
</SidebarItem>
<SidebarItem
label="消息"
href="#"
on:click={() => {
activeUrl = "#消息";
}}
{spanClass}
>
<svelte:fragment slot="icon">
<MailBoxSolid
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>
<svelte:fragment slot="subtext">
<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}
</span>
</svelte:fragment>
</SidebarItem>
<SidebarItem
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>

27
src/lib/Image.svelte Normal file
View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { fetch } from "@tauri-apps/plugin-http";
export let src = "";
export let iclass = "";
let b = "";
async function getImage(url: string) {
if (!url) {
return "";
}
const response = await fetch(url, {
method: "GET",
});
console.log(response.status); // e.g. 200
console.log(response.statusText); // e.g. "OK"
return URL.createObjectURL(await response.blob());
}
async function init() {
try {
b = await getImage(src);
} catch (e) {
console.error(e);
}
}
init();
</script>
<img src={b} class={iclass} alt="" />

70
src/lib/Messages.svelte Normal file
View File

@@ -0,0 +1,70 @@
<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>

403
src/lib/Player.svelte Normal file
View File

@@ -0,0 +1,403 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { AccountInfo, AccountItem } from "./db";
export let port;
export let room_id;
export let ts;
export let start = 0;
export let end = 0;
let show_detail = false;
async function init() {
const video = document.getElementById("video") as HTMLVideoElement;
const ui = video["ui"];
const controls = ui.getControls();
const player = controls.getPlayer();
const config = {
seekBarColors: {
base: "rgba(255,255,255,.2)",
buffered: "rgba(255,255,255,.4)",
played: "rgb(255,0,0)",
},
};
ui.configure(config);
// Attach player and UI to the window to make it easy to access in the JS console.
(window as any).player = player;
(window as any).ui = ui;
try {
await player.load(
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`
);
// This runs if the asynchronous load is successful.
console.log("The video has now been loaded!");
} catch (error) {
console.error("Error code", error.code, "object", error);
if (error.code == 3000) {
// reload
location.reload();
}
}
player.addEventListener("ended", async () => {
location.reload();
});
document.getElementsByClassName("shaka-overflow-menu-button")[0].remove();
document.getElementsByClassName("shaka-fullscreen-button")[0].remove();
// add self-defined element in shaka-bottom-controls.shaka-no-propagation (second seekbar)
const shakaBottomControls = document.querySelector(
".shaka-bottom-controls.shaka-no-propagation"
);
const selfSeekbar = document.createElement("div");
selfSeekbar.className = "shaka-seek-bar shaka-no-propagation";
selfSeekbar.innerHTML = `
<div class="shaka-seek-bar-container self-defined" style="background-color: gray; margin: 4px 10px 4px 10px;">
<div class="shaka-seek-bar shaka-no-propagation">
<div class="shaka-seek-bar-buffered" style="width: 0%;"></div>
<div class="shaka-seek-bar-played" style="width: 0%;"></div>
<div class="shaka-seek-bar-hover" style="transform: translateX(0px);"></div>
<div class="shaka-seek-bar-hit-target"></div>
</div>
</div>
`;
shakaBottomControls.appendChild(selfSeekbar);
// add to shaka-spacer
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
if (isLive()) {
// add a account select
const accountSelect = document.createElement("select");
accountSelect.style.height = "30px";
accountSelect.style.minWidth = "100px";
accountSelect.style.backgroundColor = "rgba(0, 0, 0, 0)";
accountSelect.style.color = "white";
accountSelect.style.border = "1px solid gray";
accountSelect.style.padding = "0 10px";
accountSelect.style.boxSizing = "border-box";
accountSelect.style.fontSize = "1em";
// get accounts from tauri
const account_info = (await invoke("get_accounts")) as AccountInfo;
account_info.accounts.forEach((account) => {
const option = document.createElement("option");
option.value = account.uid.toString();
option.text = account.name;
accountSelect.appendChild(option);
});
// add a danmaku send input
const danmakuInput = document.createElement("input");
danmakuInput.type = "text";
danmakuInput.placeholder = "回车发送弹幕";
danmakuInput.style.width = "50%";
danmakuInput.style.height = "30px";
danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)";
danmakuInput.style.color = "white";
danmakuInput.style.border = "1px solid gray";
danmakuInput.style.padding = "0 10px";
danmakuInput.style.boxSizing = "border-box";
danmakuInput.style.fontSize = "1em";
danmakuInput.addEventListener("keydown", async (e) => {
if (e.key === "Enter") {
const value = danmakuInput.value;
if (value) {
// get account uid from select
const uid = parseInt(accountSelect.value);
await invoke("send_danmaku", {
uid,
roomId: room_id,
ts,
message: value,
});
danmakuInput.value = "";
}
}
});
let danmu_enabled = true;
// create a danmaku toggle button
const danmakuToggle = document.createElement("button");
danmakuToggle.innerText = "弹幕已开启";
danmakuToggle.style.height = "30px";
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
danmakuToggle.style.color = "white";
danmakuToggle.style.border = "1px solid gray";
danmakuToggle.style.padding = "0 10px";
danmakuToggle.style.boxSizing = "border-box";
danmakuToggle.style.fontSize = "1em";
danmakuToggle.addEventListener("click", async () => {
danmu_enabled = !danmu_enabled;
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
// clear background color
danmakuToggle.style.backgroundColor = danmu_enabled
? "rgba(0, 128, 255, 0.5)"
: "rgba(255, 0, 0, 0.5)";
});
// create a area that overlay half top of the video, which shows danmakus floating from right to left
const overlay = document.createElement("div");
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.position = "absolute";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.pointerEvents = "none";
overlay.style.zIndex = "30";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.flexDirection = "column";
overlay.style.paddingTop = "10%";
// place overlay to the top of the video
video.parentElement.appendChild(overlay);
// Store the positions of the last few danmakus to avoid overlap
const danmakuPositions = [];
// listen to danmaku event
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");
danmaku.style.position = "absolute";
// Calculate a random position for the danmaku
let topPosition;
let attempts = 0;
do {
topPosition = Math.random() * 30;
attempts++;
} while (
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
attempts < 10
);
// Record the position
danmakuPositions.push(topPosition);
if (danmakuPositions.length > 10) {
danmakuPositions.shift(); // Keep the last 10 positions
}
danmaku.style.top = `${topPosition}%`;
danmaku.style.right = "0";
danmaku.style.color = "white";
danmaku.style.fontSize = "1.2em";
danmaku.style.whiteSpace = "nowrap";
danmaku.style.transform = "translateX(100%)";
danmaku.style.transition = "transform 10s linear";
danmaku.style.pointerEvents = "none";
danmaku.style.margin = "0";
danmaku.style.padding = "0";
danmaku.style.zIndex = "500";
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
danmaku.innerText = event.payload;
overlay.appendChild(danmaku);
requestAnimationFrame(() => {
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
});
danmaku.addEventListener("transitionend", () => {
overlay.removeChild(danmaku);
});
});
shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
shakaSpacer.appendChild(danmakuToggle);
}
// create a playback rate select to of shaka-spacer
const playbackRateSelect = document.createElement("select");
playbackRateSelect.style.height = "30px";
playbackRateSelect.style.minWidth = "60px";
playbackRateSelect.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
playbackRateSelect.style.color = "white";
playbackRateSelect.style.border = "1px solid gray";
playbackRateSelect.style.padding = "0 10px";
playbackRateSelect.style.boxSizing = "border-box";
playbackRateSelect.style.fontSize = "1em";
playbackRateSelect.style.right = "10px";
playbackRateSelect.style.position = "absolute";
playbackRateSelect.innerHTML = `
<option value="0.5">0.5x</option>
<option value="1">1x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
<option value="5">5x</option>
`;
// default playback rate is 1
playbackRateSelect.value = "1";
playbackRateSelect.addEventListener("change", () => {
const rate = parseFloat(playbackRateSelect.value);
video.playbackRate = rate;
});
shakaSpacer.appendChild(playbackRateSelect);
// shaka-spacer should be flex-direction: column
shakaSpacer.style.flexDirection = "column";
function isLive() {
return player.isLive();
}
function get_total() {
return player.seekRange().end;
}
// add keydown event listener for '[' and ']' to control range
document.addEventListener("keydown", async (e) => {
const target = e.target as HTMLInputElement;
if (
(target.tagName.toLowerCase() === "input" && target.type === "text") ||
target.tagName.toLowerCase() === "textarea"
) {
return;
}
switch (e.key) {
case "[":
start = parseFloat(video.currentTime.toFixed(2));
if (end < start) {
end = get_total();
}
console.log(start, end);
break;
case "]":
end = parseFloat(video.currentTime.toFixed(2));
if (start > end) {
start = 0;
}
console.log(start, end);
break;
case " ":
if (e.repeat) {
break;
}
if (video.paused) {
video.play();
} else {
video.pause();
}
break;
case "m":
if (e.repeat) {
break;
}
video.muted = !video.muted;
break;
case "ArrowLeft":
video.currentTime -= 3;
break;
case "ArrowRight":
video.currentTime += 3;
break;
case "q":
video.currentTime = start;
break;
case "e":
if (end == 0) {
video.currentTime = get_total();
} else {
video.currentTime = end;
}
break;
case "c":
start = 0;
end = 0;
break;
case "h":
show_detail = !show_detail;
break;
}
});
function updateSeekbar() {
const total = get_total();
const first_point = start / total;
const second_point = end / total;
// 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) ${
first_point * 100
}%, rgb(0, 255, 0) ${first_point * 100}%, rgb(0, 255, 0) ${
second_point * 100
}%, rgba(255, 255, 255, 0.4) ${
second_point * 100
}%, rgba(255, 255, 255, 0.4) ${
first_point * 100
}%, rgba(255, 255, 255, 0.2) ${first_point * 100}%)`;
requestAnimationFrame(updateSeekbar);
}
requestAnimationFrame(updateSeekbar);
}
// receive tauri emit
document.addEventListener("shaka-ui-loaded", init);
// set body background color to black
document.body.style.backgroundColor = "black";
</script>
<section id="wrap">
<div
class="youtube-theme"
data-shaka-player-container
style="width: 100%; height: 100vh;"
>
<!-- svelte-ignore a11y-media-has-caption -->
<video autoplay data-shaka-player id="video"></video>
</div>
</section>
<div id="overlay">
<p>
快捷键说明
<kbd>h</kbd>展开
</p>
{#if show_detail}
<span>
<p><kbd>Esc</kbd>关闭窗口</p>
<p><kbd>Space</kbd>播放/暂停</p>
<p><kbd>[</kbd>设定选区开始</p>
<p><kbd>]</kbd>设定选区结束</p>
<p><kbd>q</kbd>跳转到选区开始</p>
<p><kbd>e</kbd>跳转到选区结束</p>
<p><kbd></kbd>前进</p>
<p><kbd></kbd>后退</p>
<p><kbd>c</kbd>清除选区</p>
<p><kbd>m</kbd>静音</p>
</span>
{/if}
</div>
<style>
video {
width: 100%;
height: 100%;
}
p {
margin: 0;
}
kbd {
border: 1px solid white;
padding: 0 0.2em;
border-radius: 0.2em;
margin: 4px;
}
#overlay {
position: fixed;
top: 8px;
left: 8px;
border-radius: 6px;
padding: 8px;
flex-direction: column;
display: flex;
background-color: rgba(0, 0, 0, 0.5);
color: white;
font-size: 0.8em;
pointer-events: none;
}
</style>

333
src/lib/Room.svelte Normal file
View File

@@ -0,0 +1,333 @@
<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,626 +0,0 @@
<script lang="ts">
import { invoke, convertFileSrc } from "@tauri-apps/api/tauri";
import { fetch, ResponseType } from "@tauri-apps/api/http";
import { message, save } from "@tauri-apps/api/dialog";
import { open } from "@tauri-apps/api/shell";
import { copyFile, exists, removeFile } from "@tauri-apps/api/fs";
import QRCode from 'qrcode';
interface Summary {
count: number;
rooms: {
room_id: number;
room_title: string;
room_cover: string;
room_keyframe: string;
user_id: number;
user_name: string;
user_sign: string;
user_avatar: string;
live_status: boolean;
total_length: number;
max_len: number;
}[];
}
let summary: Summary;
async function setup() {
console.log("setup");
await invoke("init_recorders");
await update_summary();
await get_config();
setInterval(async () => {
await update_summary();
}, 2000);
}
async function update_summary() {
let _summary: Summary = await invoke("get_summary");
_summary.rooms = await Promise.all(
_summary.rooms.map(async (room) => {
room.user_avatar = await getImage(room.user_avatar);
room.room_cover = await getImage(room.room_cover);
room.room_keyframe = await getImage(room.room_keyframe);
return room;
})
);
summary = _summary;
}
async function getImage(url) {
const response = await fetch<Uint8Array>(url, {
method: "GET",
timeout: 30,
responseType: ResponseType.Binary,
});
const binaryArray = new Uint8Array(response.data);
var blob = new Blob([binaryArray], {
type: response.headers["content-type"],
});
return URL.createObjectURL(blob);
}
setup();
let add_model = {
room_id: "",
};
async function add_room() {
let room_id = parseInt(add_model.room_id);
if (Number.isNaN(room_id) || room_id < 0) {
await message("请输入正确的房间号", "无效的房间号");
return;
}
invoke("add_recorder", { roomId: room_id }).catch(async (e) => {
await message("请输入正确的房间号:" + e, "无效的房间号");
});
}
async function remove_room(room_id) {
await invoke("remove_recorder", { roomId: room_id });
}
let clip_model = {
room: 0,
title: "",
max_len: 100,
value: 30,
loading: false,
error: false,
error_content: "",
video: false,
video_src: "",
};
async function clip(room, len) {
return invoke("clip", { roomId: room, len: len });
}
async function show_in_folder(path) {
await invoke("show_in_folder", { path });
}
let setting_model = {
open: false,
changed: false,
cach_len: 300,
cache_path: "",
clip_path: "",
admins: "",
login: false,
uid: "",
};
interface Config {
admin_uid: number[];
cache: string;
max_len: number;
output: string;
login: boolean;
uid: string;
}
async function get_config() {
let config: Config = await invoke("get_config");
setting_model.changed = false;
setting_model.cach_len = config.max_len;
setting_model.cache_path = config.cache;
setting_model.clip_path = config.output;
setting_model.admins = config.admin_uid.join(",");
setting_model.login = config.login;
setting_model.uid = config.uid;
console.log(config);
}
async function apply_config() {
await invoke("set_cache_path", { cachePath: setting_model.cache_path });
await invoke("set_output_path", { outputPath: setting_model.clip_path });
await invoke("set_max_len", { len: setting_model.cach_len });
await invoke("set_admins", {
admins: setting_model.admins.split(",").map((x) => parseInt(x)),
});
}
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);
})
console.log(qr_info);
}
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);
setting_model.login = true;
setting_model.uid = qr_status.cookies.match(/DedeUserID=(\d+)/)[1];
await invoke("set_cookies", { cookies: qr_status.cookies });
}
}
</script>
<div>
<div>
<table class="table table-zebra x-full w-full">
<!-- head -->
<thead>
<tr>
<th>直播间</th>
<th>缓存时长</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{#if summary}
{#each summary.rooms as room}
<tr>
<td>
<div class="flex items-center space-x-3">
<div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="flex w-48 h-27 cursor-pointer"
on:click={(e) => {
open("https://live.bilibili.com/" + room.room_id);
}}
>
<img
src={room.room_cover}
alt={room.room_title}
on:mousemove={(e) => {
e.currentTarget.src = room.room_keyframe;
}}
on:mouseleave={(e) => {
e.currentTarget.src = room.room_cover;
}}
/>
</div>
</div>
<div>
<span class="bold">{room.room_title}</span>
<br />
<span class="badge">房间号:{room.room_id}</span>
</div>
</div>
</td>
<td
><div
class="radial-progress bg-primary text-primary-content border-4 border-primary"
style="--value:{(room.total_length * 100) / room.max_len};"
>
{Number(room.total_length).toFixed(1)}s
</div></td
>
<td>
<span class="badge" class:badge-success={room.live_status}
>{room.live_status ? "直播中" : "未开播"}</span
>
</td>
<td>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<label
for="save-modal"
class="btn btn-sm btn-success btn-square"
on:click={(_) => {
clip_model.max_len = room.max_len;
clip_model.room = room.room_id;
clip_model.title = room.room_title;
clip_model.video = false;
}}
>
<svg
width="24px"
height="24px"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
><g id="SVGRepo_bgCarrier" stroke-width="0" /><g
id="SVGRepo_tracerCarrier"
stroke-linecap="round"
stroke-linejoin="round"
/><g id="SVGRepo_iconCarrier">
<g id="System / Save">
<path
id="Vector"
d="M17 21.0002L7 21M17 21.0002L17.8031 21C18.921 21 19.48 21 19.9074 20.7822C20.2837 20.5905 20.5905 20.2843 20.7822 19.908C21 19.4806 21 18.921 21 17.8031V9.21955C21 8.77072 21 8.54521 20.9521 8.33105C20.9095 8.14 20.8393 7.95652 20.7432 7.78595C20.6366 7.59674 20.487 7.43055 20.1929 7.10378L17.4377 4.04241C17.0969 3.66374 16.9242 3.47181 16.7168 3.33398C16.5303 3.21 16.3242 3.11858 16.1073 3.06287C15.8625 3 15.5998 3 15.075 3H6.2002C5.08009 3 4.51962 3 4.0918 3.21799C3.71547 3.40973 3.40973 3.71547 3.21799 4.0918C3 4.51962 3 5.08009 3 6.2002V17.8002C3 18.9203 3 19.4796 3.21799 19.9074C3.40973 20.2837 3.71547 20.5905 4.0918 20.7822C4.5192 21 5.07899 21 6.19691 21H7M17 21.0002V17.1969C17 16.079 17 15.5192 16.7822 15.0918C16.5905 14.7155 16.2837 14.4097 15.9074 14.218C15.4796 14 14.9203 14 13.8002 14H10.2002C9.08009 14 8.51962 14 8.0918 14.218C7.71547 14.4097 7.40973 14.7155 7.21799 15.0918C7 15.5196 7 16.0801 7 17.2002V21M15 7H9"
stroke="#d4fad8"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</g></svg
>
</label>
<button
class="btn btn-sm btn-error btn-square"
on:click={() => {
remove_room(room.room_id).then(() => {
update_summary();
});
}}
>
<svg
width="24px"
height="24px"
viewBox="0 -0.5 21 21"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
fill="#f1cdc9"
><g id="SVGRepo_bgCarrier" stroke-width="0" /><g
id="SVGRepo_tracerCarrier"
stroke-linecap="round"
stroke-linejoin="round"
/><g id="SVGRepo_iconCarrier">
<title>delete [#1487]</title>
<desc>Created with Sketch.</desc> <defs />
<g
id="Page-1"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<g
id="Dribbble-Light-Preview"
transform="translate(-179.000000, -360.000000)"
fill="#f1cdc9"
>
<g
id="icons"
transform="translate(56.000000, 160.000000)"
>
<path
d="M130.35,216 L132.45,216 L132.45,208 L130.35,208 L130.35,216 Z M134.55,216 L136.65,216 L136.65,208 L134.55,208 L134.55,216 Z M128.25,218 L138.75,218 L138.75,206 L128.25,206 L128.25,218 Z M130.35,204 L136.65,204 L136.65,202 L130.35,202 L130.35,204 Z M138.75,204 L138.75,200 L128.25,200 L128.25,204 L123,204 L123,206 L126.15,206 L126.15,220 L140.85,220 L140.85,206 L144,206 L144,204 L138.75,204 Z"
id="delete-[#1487]"
/>
</g>
</g>
</g>
</g></svg
>
</button>
</td>
</tr>
{/each}
{:else}
<tr>
<progress class="progress w-56" />
</tr>
{/if}
</tbody>
</table>
<div class="fixed bottom-6 right-6 flex flex-col">
<div class="tooltip tooltip-left" data-tip="新增直播间">
<label class="btn btn-circle" for="add-modal">
<svg
width="48px"
height="48px"
viewBox="-2.4 -2.4 28.80 28.80"
fill="white"
xmlns="http://www.w3.org/2000/svg"
><g id="SVGRepo_bgCarrier" stroke-width="0" /><g
id="SVGRepo_tracerCarrier"
stroke-linecap="round"
stroke-linejoin="round"
/><g id="SVGRepo_iconCarrier">
<g id="Edit / Add_Plus">
<path
id="Vector"
d="M6 12H12M12 12H18M12 12V18M12 12V6"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</g></svg
>
</label>
</div>
<div class="tooltip tooltip-left" data-tip="设置">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<label
class="btn btn-circle mt-2"
for="setting-modal"
on:click={() => get_config()}
>
<svg
width="36px"
height="36px"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
><g id="SVGRepo_bgCarrier" stroke-width="0" /><g
id="SVGRepo_tracerCarrier"
stroke-linecap="round"
stroke-linejoin="round"
/><g id="SVGRepo_iconCarrier">
<path
d="M9 22H15C20 22 22 20 22 15V9C22 4 20 2 15 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22Z"
stroke="#ffffff"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15.5699 18.5001V14.6001"
stroke="#ffffff"
stroke-width="1.5"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15.5699 7.45V5.5"
stroke="#ffffff"
stroke-width="1.5"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15.57 12.65C17.0059 12.65 18.17 11.4859 18.17 10.05C18.17 8.61401 17.0059 7.44995 15.57 7.44995C14.134 7.44995 12.97 8.61401 12.97 10.05C12.97 11.4859 14.134 12.65 15.57 12.65Z"
stroke="#ffffff"
stroke-width="1.5"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8.43005 18.5V16.55"
stroke="#ffffff"
stroke-width="1.5"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8.43005 9.4V5.5"
stroke="#ffffff"
stroke-width="1.5"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8.42996 16.5501C9.8659 16.5501 11.03 15.386 11.03 13.9501C11.03 12.5142 9.8659 11.3501 8.42996 11.3501C6.99402 11.3501 5.82996 12.5142 5.82996 13.9501C5.82996 15.386 6.99402 16.5501 8.42996 16.5501Z"
stroke="#ffffff"
stroke-width="1.5"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g></svg
>
</label>
</div>
</div>
</div>
<input type="checkbox" id="add-modal" class="modal-toggle" />
<label for="add-modal" class="modal cursor-pointer">
<label class="modal-box relative" for="">
<h3 class="text-lg font-bold mb-4">新增直播间</h3>
<div class="flex justify-center">
<input
type="text"
placeholder="输入直播间号"
class="input input-bordered input-primary w-full max-w-xs mx-2"
bind:value={add_model.room_id}
/>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<label class="btn btn-primary" for="add-modal" on:click={add_room}
>添加</label
>
</div>
</label>
</label>
<input type="checkbox" id="save-modal" class="modal-toggle" />
<!-- svelte-ignore a11y-click-events-have-key-events -->
<label for="save-modal" class="modal cursor-pointer border-2">
<label class="modal-box relative" for="">
<h3 class="text-lg font-bold mb-4">生成切片 - {clip_model.title}</h3>
{#if clip_model.video}
<div class="mb-6">
<!-- svelte-ignore a11y-media-has-caption -->
<video src={convertFileSrc(clip_model.video_src)} controls />
</div>
{/if}
<div class="flex flex-col items-center">
最近 {clip_model.value}s
<input
type="range"
min="10"
max={clip_model.max_len}
bind:value={clip_model.value}
class="range range-primary mt-4"
/>
<div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-label-has-associated-control -->
<label
class="btn btn-primary my-4"
class:loading={clip_model.loading}
on:click={() => {
clip_model.loading = true;
clip(clip_model.room, clip_model.value)
.then((f) => {
exists(String(f)).then((result) => {
clip_model.loading = false;
if (result) {
clip_model.error = false;
clip_model.video = true;
clip_model.video_src = String(f);
} else {
clip_model.error = true;
clip_model.error_content = "生成失败,请重试";
}
});
})
.catch((e) => {
clip_model.loading = false;
clip_model.error = true;
clip_model.error_content = e;
});
}}>生成切片</label
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<label
class="btn btn-secondary"
for=""
on:click={(e) => {
show_in_folder(setting_model.clip_path);
}}>打开切片文件夹</label
>
</div>
{#if clip_model.error}
<div class="alert alert-error shadow-lg">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current flex-shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
<span>生成切片失败:{clip_model.error_content}</span>
</div>
</div>
{/if}
</div>
</label>
</label>
<!-- Setting modal Part -->
<input
type="checkbox"
id="setting-modal"
class="modal-toggle"
bind:checked={setting_model.open}
/>
<label for="setting-modal" class="modal cursor-pointer">
<label class="modal-box relative" for="">
<h3 class="text-lg font-bold">设置</h3>
<div class="flex flex-col">
{#if setting_model.login}
<div class="flex items-center flex-col">
<div class="badge badge-success">已登录UID{setting_model.uid}</div>
<button
class="btn btn-sm btn-error my-4"
on:click={() => {
setting_model.login = false;
invoke("logout");
}}>退出登录</button
>
</div>
{:else}
<div class="flex items-center flex-col">
<canvas id="qr" style="display: none;"></canvas>
<button
class="btn btn-sm btn-primary my-4"
on:click={() => {
handle_qr();
}}>获取/刷新登录二维码</button>
</div>
{/if}
<hr />
<label class="flex items-center my-2"
>缓存时长:<input
type="number"
class="input input-sm input-bordered input-primary"
bind:value={setting_model.cach_len}
on:change={() => {
setting_model.changed = true;
}}
/></label
>
<label class="flex items-center my-2"
>缓存目录:<input
type="text"
class="input input-sm input-bordered input-primary"
bind:value={setting_model.cache_path}
on:change={() => {
setting_model.changed = true;
}}
/></label
>
<label class="flex items-center my-2"
>切片目录:<input
type="text"
class="input input-sm input-bordered input-primary"
bind:value={setting_model.clip_path}
on:change={() => {
setting_model.changed = true;
}}
/></label
>
<label class="flex items-center my-2"
>管理员UID<input
type="text"
class="input input-sm input-bordered input-primary"
bind:value={setting_model.admins}
on:change={() => {
setting_model.changed = true;
}}
/></label
>
<div class="text-sm">
相关说明管理员UID可添加多个使用“,”分隔。设定为管理员的用户可以在直播间发送
<div class="badge badge-outline">/clip + 时长</div>
弹幕来触发切片, 例如:
<div class="badge badge-outline">/clip 30</div>
将会保存最近的30s录播
</div>
<button
class="btn btn-sm btn-primary my-4"
disabled={!setting_model.changed}
on:click={() => {
apply_config();
setting_model.open = false;
}}>应用</button
>
</div>
</label>
</label>
</div>
<style>
</style>

132
src/lib/Setting.svelte Normal file
View File

@@ -0,0 +1,132 @@
<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>

131
src/lib/Summary.svelte Normal file
View File

@@ -0,0 +1,131 @@
<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>

81
src/lib/TitleBar.svelte Normal file
View File

@@ -0,0 +1,81 @@
<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>

111
src/lib/db.ts Normal file
View File

@@ -0,0 +1,111 @@
import Database from "@tauri-apps/plugin-sql";
export const db = await Database.load("sqlite:data.db");
// sql: r#"
// CREATE TABLE records (live_id INTEGER PRIMARY KEY, room_id INTEGER, length INTEGER, size INTEGER, created_at TEXT);
// CREATE TABLE danmu_statistics (live_id INTEGER PRIMARY KEY, room_id INTEGER, value INTEGER, time_point TEXT);
// CREATE TABLE messages (id INTEGER PRIMARY KEY, title TEXT, content TEXT, read INTEGER, created_at TEXT);
// CREATE TABLE videos (id INTEGER PRIMARY KEY, file TEXT, length INTEGER, size INTEGER, status INTEGER, title TEXT, desc TEXT, tags TEXT, area INTEGER);
// "#,
export interface RecorderItem {
room_id: number;
created_at: string;
}
export interface AccountItem {
uid: number;
name: string;
avatar: string;
csrf: string;
cookies: string;
created_at: string;
}
export interface MessageItem {
id: number;
title: string;
content: string;
read: number;
created_at: string;
}
// from RecordRow
export interface RecordItem {
title: string;
live_id: number;
room_id: number;
length: number;
size: number;
created_at: string;
}
export interface AccountInfo {
primary_uid: number;
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");
}
}

98
src/lib/interface.ts Normal file
View File

@@ -0,0 +1,98 @@
export interface RoomInfo {
live_status: number;
room_cover_url: string;
room_id: number;
room_keyframe_url: string;
room_title: string;
user_id: string;
}
export interface UserInfo {
user_id: string;
user_name: string;
user_sign: string;
user_avatar_url: string;
}
export interface RecorderInfo {
room_id: number;
room_info: RoomInfo;
user_info: UserInfo;
total_length: number;
current_ts: number;
live_status: boolean;
}
export interface RecorderList {
count: number;
recorders: RecorderInfo[];
}
export interface Subtitle {
open: 0 | 1;
lan: string;
}
export interface Video {
title: string;
filename: string;
desc: string;
cid: number;
}
export interface VideoItem {
id: number;
room_id: number;
cover: string;
file: string;
length: number;
size: number;
status: number;
bvid: string;
title: string;
desc: string;
tags: string;
area: number;
created_at: string;
}
export interface Profile {
videos: Video[];
cover: string;
cover43: string | null;
title: string;
copyright: 1 | 2;
tid: number;
tag: string;
desc_format_id: number;
desc: string;
recreate: number;
dynamic: string;
interactive: 0 | 1;
act_reserve_create: 0 | 1;
no_disturbance: 0 | 1;
no_reprint: 0 | 1;
subtitle: Subtitle;
dolby: 0 | 1;
lossless_music: 0 | 1;
up_selection_reply: boolean;
up_close_reply: boolean;
up_close_danmu: boolean;
web_os: 0 | 1;
}
export interface Config {
cache: string;
output: string;
primary_uid: number;
live_start_notify: boolean;
live_end_notify: boolean;
clip_notify: boolean;
post_notify: boolean;
}
export interface DiskInfo {
disk: string;
total: number;
free: number;
}

10
src/live_main.ts Normal file
View File

@@ -0,0 +1,10 @@
import "./styles.css";
import App from "./AppLive.svelte";
const app = new App({
target: document.getElementById("app"),
});
export default app;

View File

@@ -4,16 +4,8 @@
html,
body {
background-color: #131c39;
height: 100%;
width: 100%;
user-select: none;
}
.modal {
background-color: #0000008f;
}
.modal-box {
@apply rounded-md;
}

View File

@@ -1,9 +1,27 @@
module.exports = {
daisyui: {
themes: [
"aqua"
],
},
content: ['./src/**/*.{svelte,js,ts}'],
plugins: [require('daisyui')],
import flowbitePlugin from 'flowbite/plugin'
export default {
content: ['./src/**/*.{html,js,svelte,ts}', './node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {
colors: {
// flowbite-svelte
primary: {
50: '#FFF5F2',
100: '#FFF1EE',
200: '#FFE4DE',
300: '#FFD5CC',
400: '#FFBCAD',
500: '#FE795D',
600: '#EF562F',
700: '#EB4F27',
800: '#CC4522',
900: '#A5371B'
}
}
}
},
plugins: [flowbitePlugin]
};

View File

@@ -1,4 +1,5 @@
import { defineConfig } from "vite";
import { resolve } from "path";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import sveltePreprocess from "svelte-preprocess";
@@ -30,6 +31,12 @@ export default defineConfig(async () => ({
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ["VITE_", "TAURI_"],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
live: resolve(__dirname, "live_index.html"),
},
},
// Tauri supports es2021
target: process.env.TAURI_PLATFORM == "windows" ? "chrome105" : "safari13",
// don't minify for debug builds

601
yarn.lock
View File

@@ -14,11 +14,136 @@
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@esbuild/android-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622"
integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==
"@esbuild/android-arm@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682"
integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==
"@esbuild/android-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2"
integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==
"@esbuild/darwin-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1"
integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==
"@esbuild/darwin-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d"
integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==
"@esbuild/freebsd-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54"
integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==
"@esbuild/freebsd-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e"
integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==
"@esbuild/linux-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0"
integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==
"@esbuild/linux-arm@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0"
integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==
"@esbuild/linux-ia32@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7"
integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==
"@esbuild/linux-loong64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d"
integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==
"@esbuild/linux-mips64el@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231"
integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==
"@esbuild/linux-ppc64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb"
integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==
"@esbuild/linux-riscv64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6"
integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==
"@esbuild/linux-s390x@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071"
integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==
"@esbuild/linux-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338"
integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==
"@esbuild/netbsd-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1"
integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==
"@esbuild/openbsd-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae"
integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==
"@esbuild/sunos-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d"
integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==
"@esbuild/win32-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9"
integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==
"@esbuild/win32-ia32@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102"
integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==
"@esbuild/win32-x64@0.18.20":
version "0.18.20"
resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz"
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
"@floating-ui/core@^1.6.0":
version "1.6.7"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.7.tgz#7602367795a390ff0662efd1c7ae8ca74e75fb12"
integrity sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==
dependencies:
"@floating-ui/utils" "^0.2.7"
"@floating-ui/dom@^1.6.10":
version "1.6.10"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.10.tgz#b74c32f34a50336c86dcf1f1c845cf3a39e26d6f"
integrity sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==
dependencies:
"@floating-ui/core" "^1.6.0"
"@floating-ui/utils" "^0.2.7"
"@floating-ui/utils@^0.2.7":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.7.tgz#d0ece53ce99ab5a8e37ebdfe5e32452a2bfc073e"
integrity sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
@@ -55,14 +180,6 @@
resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz"
integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==
"@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.24":
version "0.3.25"
resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz"
integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==
dependencies:
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@jridgewell/trace-mapping@0.3.9":
version "0.3.9"
resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz"
@@ -71,6 +188,14 @@
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.24":
version "0.3.25"
resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz"
integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==
dependencies:
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -79,7 +204,7 @@
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
version "2.0.5"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -97,6 +222,32 @@
resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@popperjs/core@^2.9.3":
version "2.11.8"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@rollup/plugin-node-resolve@^15.2.3":
version "15.2.3"
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz#e5e0b059bd85ca57489492f295ce88c2d4b0daf9"
integrity sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==
dependencies:
"@rollup/pluginutils" "^5.0.1"
"@types/resolve" "1.20.2"
deepmerge "^4.2.2"
is-builtin-module "^3.2.1"
is-module "^1.0.0"
resolve "^1.22.1"
"@rollup/pluginutils@^5.0.1":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz#7e53eddc8c7f483a4ad0b94afb1f7f5fd3c771e0"
integrity sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==
dependencies:
"@types/estree" "^1.0.0"
estree-walker "^2.0.2"
picomatch "^2.3.1"
"@sveltejs/vite-plugin-svelte-inspector@^1.0.4":
version "1.0.4"
resolved "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-1.0.4.tgz"
@@ -104,7 +255,7 @@
dependencies:
debug "^4.3.4"
"@sveltejs/vite-plugin-svelte@^2.0.0", "@sveltejs/vite-plugin-svelte@^2.2.0":
"@sveltejs/vite-plugin-svelte@^2.0.0":
version "2.5.3"
resolved "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.5.3.tgz"
integrity sha512-erhNtXxE5/6xGZz/M9eXsmI7Pxa6MS7jyTy06zN3Ck++ldrppOnOlJwHHTsMC7DHDQdgUp4NAc4cDNQ9eGdB/w==
@@ -117,31 +268,135 @@
svelte-hmr "^0.15.3"
vitefu "^0.2.4"
"@tauri-apps/api@^1.2.0":
version "1.6.0"
resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-1.6.0.tgz"
integrity sha512-rqI++FWClU5I2UBp4HXFvl+sBWkdigBkxnpJDQUWttNyG7IZP4FwQGhTNL5EOw0vI8i6eSAJ5frLqO7n7jbJdg==
"@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/cli-win32-x64-msvc@1.6.1":
version "1.6.1"
resolved "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-1.6.1.tgz"
integrity sha512-WEzQzBgcaqjZoA5M/KOupHmt8W3QQ20vwETXpGEMPd7spj4eZsRv/2ZDuCz4ELbai1XlIsTITFNe2LlJjzqISA==
"@tauri-apps/api@^2.0.0":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.2.tgz#266767f4a4641014e86a000e7e02e1a344ced45a"
integrity sha512-3wSwmG+1kr6WrgAFKK5ijkNFPp8TT3FLj3YHUb5EwMO+3FxX4uWlfSWkeeBy+Kc1RsKzugtYLuuya+98Flj+3w==
"@tauri-apps/cli@^1.2.2":
version "1.6.1"
resolved "https://registry.npmjs.org/@tauri-apps/cli/-/cli-1.6.1.tgz"
integrity sha512-2S8WGmkz54Z9WxpaFVbUYsTiwx5OIEmdD5DDWRygX9VhaWwZg0y6DctsUtCRVre9I/Un/hTnmqkhZqPamCEx8A==
"@tauri-apps/api@^2.0.0-rc.4":
version "2.0.0-rc.4"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-rc.4.tgz#2b4c3493d86382981787c52006c6c9e5bf16bc08"
integrity sha512-UNiIhhKG08j4ooss2oEEVexffmWkgkYlC2M3GcX3VPtNsqFgVNL8Mcw/4Y7rO9M9S+ffAMnLOF5ypzyuyb8tyg==
"@tauri-apps/cli-darwin-arm64@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.0.2.tgz#5a078381ce0e9e83a710fa5ae812c8709ca993cc"
integrity sha512-B+/a8Q6wAqmB4A4HVeK0oQP5TdQGKW60ZLOI9O2ktH2HPr9ETr3XkwXPuJ2uAOuGEgtRZHBgFOIgG000vMnKlg==
"@tauri-apps/cli-darwin-x64@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.0.2.tgz#87db71cc8dfe2196b33cf5ab26b99a4fa3fa01ac"
integrity sha512-kaurhn6XT4gAVCPAQSSHl/CHFxTS0ljc47N7iGTSlYJ03sCWPRZeNuVa/bn6rolz9MA2JfnRnFqB1pUL6jzp9Q==
"@tauri-apps/cli-linux-arm-gnueabihf@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.0.2.tgz#0ca2dc0b563028181bb0f005d3049c01a7dd904f"
integrity sha512-bVrofjlacMxmGMcqK18iBW05tsZXOd19/MnqruFFcHSVjvkGGIXHMtUbMXnZNXBPkHDsnfytNtkY9SZGfCFaBA==
"@tauri-apps/cli-linux-arm64-gnu@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.0.2.tgz#10c0baa2255dc476707bb06619e6a9bd8874c639"
integrity sha512-7XCBn0TTBVQGnV42dXcbHPLg/9W8kJoVzuliIozvNGyRWxfXqDbQYzpI48HUQG3LgHMabcw8+pVZAfGhevLrCA==
"@tauri-apps/cli-linux-arm64-musl@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.0.2.tgz#6636e383dae70eabf9f6dd22470ad2edb9776d87"
integrity sha512-1xi2SreGVlpAL68MCsDUY63rdItUdPZreXIAcOVqvUehcJRYOa1XGSBhrV0YXRgZeh0AtKC19z6PRzcv4rosZA==
"@tauri-apps/cli-linux-x64-gnu@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.0.2.tgz#925e7714ad72eff67b42ed25a781b9f13262c296"
integrity sha512-WVjwYzPWFqZVg1fx6KSU5w47Q0VbMyaCp34qs5EcS8EIU0/RnofdzqUoOYqvgGVgNgoz7Pj5dXK2SkS8BHXMmA==
"@tauri-apps/cli-linux-x64-musl@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.0.2.tgz#7e706660196c5db7ea821ceec7e315706f73cb5e"
integrity sha512-h5miE2mctgaQNn/BbG9o1pnJcrx+VGBi2A6JFqGu934lFgSV5+s28M8Gc8AF2JgFH4hQV4IuMkeSw8Chu5Dodg==
"@tauri-apps/cli-win32-arm64-msvc@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.0.2.tgz#aaee06d53f5d42afe8bf994e42cf5679c9e1ebd3"
integrity sha512-2b8oO0+dYonahG5PfA/zoq0zlafLclfmXgqoWDZ++UiPtQHJNpNeEQ8GWbSFKGHQ494Jo6jHvazOojGRE1kqAg==
"@tauri-apps/cli-win32-ia32-msvc@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.0.2.tgz#ec6d8eef16d024eeae2ce4e3422b8a51f419e661"
integrity sha512-axgICLunFi0To3EibdCBgbST5RocsSmtM4c04+CbcX8WQQosJ9ziWlCSrrOTRr+gJERAMSvEyVUS98f6bWMw9A==
"@tauri-apps/cli-win32-x64-msvc@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.0.2.tgz#bb5d011a0d44297f148c654c05b93a71cebacaa1"
integrity sha512-JR17cM6+DyExZRgpXr2/DdqvcFYi/EKvQt8dI5R1/uQoesWd8jeNnrU7c1FG1Zmw9+pTzDztsNqEKsrNq2sNIg==
"@tauri-apps/cli@^2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli/-/cli-2.0.2.tgz#908629846f6edb1622a480f69a9469b3b9b7110e"
integrity sha512-R4ontHZvXORArERAHIidp5zRfZEshZczTiK+poslBv7AGKpQZoMw+E49zns7mOmP64i2Cq9Ci0pJvi4Rm8Okzw==
optionalDependencies:
"@tauri-apps/cli-darwin-arm64" "1.6.1"
"@tauri-apps/cli-darwin-x64" "1.6.1"
"@tauri-apps/cli-linux-arm-gnueabihf" "1.6.1"
"@tauri-apps/cli-linux-arm64-gnu" "1.6.1"
"@tauri-apps/cli-linux-arm64-musl" "1.6.1"
"@tauri-apps/cli-linux-x64-gnu" "1.6.1"
"@tauri-apps/cli-linux-x64-musl" "1.6.1"
"@tauri-apps/cli-win32-arm64-msvc" "1.6.1"
"@tauri-apps/cli-win32-ia32-msvc" "1.6.1"
"@tauri-apps/cli-win32-x64-msvc" "1.6.1"
"@tauri-apps/cli-darwin-arm64" "2.0.2"
"@tauri-apps/cli-darwin-x64" "2.0.2"
"@tauri-apps/cli-linux-arm-gnueabihf" "2.0.2"
"@tauri-apps/cli-linux-arm64-gnu" "2.0.2"
"@tauri-apps/cli-linux-arm64-musl" "2.0.2"
"@tauri-apps/cli-linux-x64-gnu" "2.0.2"
"@tauri-apps/cli-linux-x64-musl" "2.0.2"
"@tauri-apps/cli-win32-arm64-msvc" "2.0.2"
"@tauri-apps/cli-win32-ia32-msvc" "2.0.2"
"@tauri-apps/cli-win32-x64-msvc" "2.0.2"
"@tauri-apps/plugin-dialog@^2.0.0-rc.1":
version "2.0.0-rc.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.0-rc.1.tgz#a3b0fd51a547e796ff93c58172fad48ad61a9970"
integrity sha512-H28gh6BfZtjflHQ+HrmWwunDriBI3AQLAKnMs50GA6zeNUULqbQr7VXbAAKeJL/0CmWcecID4PKXVoSlaWRhEg==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/plugin-fs@^2.0.0-rc.2":
version "2.0.0-rc.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-fs/-/plugin-fs-2.0.0-rc.2.tgz#85362170983b12ad6a4808e51daff5ba4a8916fa"
integrity sha512-TFjCfso3tN4b5s2EBjqP8N2gYrPh93Ds3VNKj8pCXv4wbvnItyfG0aHO0haUsedBOHQryDwv9vDAdPX6/T0a+g==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/plugin-http@^2.0.0-rc.2":
version "2.0.0-rc.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-http/-/plugin-http-2.0.0-rc.2.tgz#385a3c0808db86b59ab0b183c85e4fd2de989cbd"
integrity sha512-s/AbbMaQPgqnIOoObvWNAjJOV17gyf9G+U6gmvjLoFbt7D6jsujOUW6fn+Oe/+rzNSEeo1ZSVrUoMen5DgM+OA==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/plugin-notification@~2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-notification/-/plugin-notification-2.0.0.tgz#7f5e75a87e4c11d28aede278063c670befd900e3"
integrity sha512-6qEDYJS7mgXZWLXA0EFL+DVCJh8sJlzSoyw6B50pxhLPVFjc5Vr5DVzl5W3mUHaYhod5wsC984eQnlCCGqxYDA==
dependencies:
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-os@^2.0.0-rc":
version "2.0.0-rc.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.0.0-rc.1.tgz#325e23e007df6d3247654eca2741e700519ab1cd"
integrity sha512-PV8zlSTmYfiN2xzILUmlDSEycS7UYbH2yXk/ZqF+qQU6/s+OVQvmSth4EhllFjcpvPbtqELvpzfjw+2qEouchA==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/plugin-shell@^2.0.0-rc.1":
version "2.0.0-rc.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0-rc.1.tgz#9facf3bbcedfa2de676cb4cfc703687377aa12a3"
integrity sha512-JtNROc0rqEwN/g93ig5pK4cl1vUo2yn+osCpY9de64cy/d9hRzof7AuYOgvt/Xcd5VPQmlgo2AGvUh5sQRSR1A==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tauri-apps/plugin-sql@^2.0.0-rc.1":
version "2.0.0-rc.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-sql/-/plugin-sql-2.0.0-rc.1.tgz#aa21b4744cab72d082297844500c4b53837df964"
integrity sha512-F8Wq+L8SdVt76WCU161WCeM5CLkh3gzKblXhXyFFJ8m+BY1P83zVyCxriems3mYULvPPtFwlg8NEgLJycS1HYQ==
dependencies:
"@tauri-apps/api" "^2.0.0-rc.4"
"@tsconfig/node10@^1.0.7":
version "1.0.11"
@@ -168,7 +423,19 @@
resolved "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-3.0.0.tgz"
integrity sha512-pYrtLtOwku/7r1i9AMONsJMVYAtk3hzOfiGNekhtq5tYBGA7unMve8RvUclKLMT3PrihvJqUmzsRGh0RP84hKg==
"@types/node@*", "@types/node@^18.7.10", "@types/node@>= 14":
"@types/estree@^1.0.0":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
"@types/node@*":
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"
resolved "https://registry.npmjs.org/@types/node/-/node-18.19.47.tgz"
integrity sha512-1f7dB3BL/bpd9tnDJrrHb66Y+cVrhxSOTGorRNdHwYTUlTay3HuTDPKo9a/4vX9pMQkhYBcAbL4jQdNlhCFP9A==
@@ -180,6 +447,23 @@
resolved "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz"
integrity sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==
"@types/qrcode@^1.5.5":
version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac"
integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==
dependencies:
"@types/node" "*"
"@types/resolve@1.20.2":
version "1.20.2"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975"
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
"@yr/monotone-cubic-spline@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz#7272d89f8e4f6fb7a1600c28c378cc18d3b577b9"
integrity sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==
acorn-walk@^8.1.1:
version "8.3.3"
resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz"
@@ -227,6 +511,19 @@ anymatch@~3.1.2:
normalize-path "^3.0.0"
picomatch "^2.0.4"
apexcharts@^3.53.0:
version "3.53.0"
resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.53.0.tgz#9ea2b4d837d9faf2c0bff79d228db48e75b2220a"
integrity sha512-QESZHZY3w9LPQ64PGh1gEdfjYjJ5Jp+Dfy0D/CLjsLOPTpXzdxwlNMqRj+vPbTcP0nAHgjWv1maDqcEq6u5olw==
dependencies:
"@yr/monotone-cubic-spline" "^1.0.3"
svg.draggable.js "^2.2.2"
svg.easing.js "^2.0.0"
svg.filter.js "^2.0.2"
svg.pathmorphing.js "^0.1.3"
svg.resize.js "^1.4.3"
svg.select.js "^3.0.1"
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz"
@@ -237,7 +534,7 @@ arg@^5.0.2:
resolved "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz"
integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
autoprefixer@^10.0.2, autoprefixer@^10.4.14:
autoprefixer@^10.4.14:
version "10.4.20"
resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz"
integrity sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==
@@ -254,6 +551,11 @@ balanced-match@^1.0.0:
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-arraybuffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
binary-extensions@^2.0.0:
version "2.3.0"
resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz"
@@ -281,7 +583,7 @@ braces@^3.0.3, braces@~3.0.2:
dependencies:
fill-range "^7.1.1"
browserslist@^4.23.3, "browserslist@>= 4.21.0":
browserslist@^4.23.3:
version "4.23.3"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz"
integrity sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==
@@ -296,6 +598,11 @@ buffer-crc32@^1.0.0:
resolved "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz"
integrity sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==
builtin-modules@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
camelcase-css@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
@@ -342,27 +649,11 @@ color-convert@^2.0.1:
dependencies:
color-name "~1.1.4"
color-name@^1.0.0, color-name@~1.1.4:
color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-string@^1.9.0:
version "1.9.1"
resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz"
integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
dependencies:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
color@^4.2:
version "4.2.3"
resolved "https://registry.npmjs.org/color/-/color-4.2.3.tgz"
integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==
dependencies:
color-convert "^2.0.1"
color-string "^1.9.0"
commander@^4.0.0:
version "4.1.1"
resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz"
@@ -387,29 +678,18 @@ cross-spawn@^7.0.0:
shebang-command "^2.0.0"
which "^2.0.1"
css-selector-tokenizer@^0.8.0:
version "0.8.0"
resolved "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz"
integrity sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==
css-line-break@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
dependencies:
cssesc "^3.0.0"
fastparse "^1.1.2"
utrie "^1.0.2"
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
daisyui@^2.51.5:
version "2.52.0"
resolved "https://registry.npmjs.org/daisyui/-/daisyui-2.52.0.tgz"
integrity sha512-LQTA5/IVXAJHBMFoeaEMfd7/akAFPPcdQPR3O9fzzcFiczneJFM73CFPnScmW2sOgn/D83cvkP854ep2T9OfTg==
dependencies:
color "^4.2"
css-selector-tokenizer "^0.8.0"
postcss-js "^4.0.0"
tailwindcss "^3"
debug@^4.3.4:
version "4.3.6"
resolved "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz"
@@ -422,7 +702,7 @@ decamelize@^1.2.0:
resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
deepmerge@^4.3.1:
deepmerge@^4.2.2, deepmerge@^4.3.1:
version "4.3.1"
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
@@ -510,6 +790,11 @@ escalade@^3.1.2:
resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz"
integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==
estree-walker@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
fast-glob@^3.3.0:
version "3.3.2"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz"
@@ -521,11 +806,6 @@ fast-glob@^3.3.0:
merge2 "^1.3.0"
micromatch "^4.0.4"
fastparse@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz"
integrity sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==
fastq@^1.6.0:
version "1.17.1"
resolved "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz"
@@ -548,6 +828,38 @@ find-up@^4.1.0:
locate-path "^5.0.0"
path-exists "^4.0.0"
flowbite-datepicker@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/flowbite-datepicker/-/flowbite-datepicker-1.3.0.tgz#60b2423dfa1013e61c50babcf8512501d8b835ee"
integrity sha512-CLVqzuoE2vkUvWYK/lJ6GzT0be5dlTbH3uuhVwyB67+PjqJWABm2wv68xhBf5BqjpBxvTSQ3mrmLHpPJ2tvrSQ==
dependencies:
"@rollup/plugin-node-resolve" "^15.2.3"
flowbite "^2.0.0"
flowbite-svelte-icons@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/flowbite-svelte-icons/-/flowbite-svelte-icons-1.6.1.tgz#10da0f5aafdbe1f34dcc44293bd2c0020482262b"
integrity sha512-Kw/7BzA6fqlFq7tBNudwX0KVU4cbyyXcMcgHTraMwGBtvBQan0RKMbvWwqm4JZNvLGAvRv1BM2EF7rzo/oam1Q==
flowbite-svelte@^0.46.16:
version "0.46.16"
resolved "https://registry.yarnpkg.com/flowbite-svelte/-/flowbite-svelte-0.46.16.tgz#f02dfd3ddf1a4f5cf9c837528ce0dacc34c02944"
integrity sha512-NkyMS/d1EwuL1cqstSUflnG9vhhBiNyUiAw51D8lfPKDfUG1iXc4+HueQw01zhHv3uSXRJRToFBrg6npxeJ3jw==
dependencies:
"@floating-ui/dom" "^1.6.10"
apexcharts "^3.53.0"
flowbite "^2.5.1"
tailwind-merge "^2.5.2"
flowbite@^2.0.0, flowbite@^2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/flowbite/-/flowbite-2.5.1.tgz#b2075c6a91b047e7514a41ec4c5437a7eaaf69e0"
integrity sha512-7jP1jy9c3QP7y+KU9lc8ueMkTyUdMDvRP+lteSWgY5TigSZjf9K1kqZxmqjhbx2gBnFQxMl1GAjVThCa8cEpKA==
dependencies:
"@popperjs/core" "^2.9.3"
flowbite-datepicker "^1.3.0"
mini-svg-data-uri "^1.4.3"
foreground-child@^3.1.0:
version "3.3.0"
resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz"
@@ -566,6 +878,11 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
@@ -626,6 +943,14 @@ hasown@^2.0.2:
dependencies:
function-bind "^1.1.2"
html2canvas@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
dependencies:
css-line-break "^2.1.0"
text-segmentation "^1.0.3"
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz"
@@ -639,11 +964,6 @@ inherits@2:
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
is-arrayish@^0.3.1:
version "0.3.2"
resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz"
integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz"
@@ -651,6 +971,13 @@ is-binary-path@~2.1.0:
dependencies:
binary-extensions "^2.0.0"
is-builtin-module@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169"
integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==
dependencies:
builtin-modules "^3.3.0"
is-core-module@^2.13.0:
version "2.15.1"
resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz"
@@ -675,6 +1002,11 @@ is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
dependencies:
is-extglob "^2.1.1"
is-module@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
is-number@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz"
@@ -761,6 +1093,11 @@ min-indent@^1.0.0:
resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz"
integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
mini-svg-data-uri@^1.4.3:
version "1.4.4"
resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939"
integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
minimatch@^3.1.1:
version "3.1.2"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz"
@@ -934,14 +1271,14 @@ postcss-import@^15.1.0:
read-cache "^1.0.0"
resolve "^1.1.7"
postcss-js@^4.0.0, postcss-js@^4.0.1:
postcss-js@^4.0.1:
version "4.0.1"
resolved "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz"
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2"
integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==
dependencies:
camelcase-css "^2.0.1"
"postcss-load-config@^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", postcss-load-config@^4.0.1:
postcss-load-config@^4.0.1:
version "4.0.2"
resolved "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz"
integrity sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==
@@ -969,7 +1306,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
"postcss@^7 || ^8", postcss@^8.0.0, postcss@^8.1.0, postcss@^8.1.6, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.27, postcss@>=8.0.9:
postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.27:
version "8.4.41"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz"
integrity sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==
@@ -1016,7 +1353,7 @@ require-main-filename@^2.0.0:
resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
resolve@^1.1.7, resolve@^1.22.2:
resolve@^1.1.7, resolve@^1.22.1, resolve@^1.22.2:
version "1.22.8"
resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz"
integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
@@ -1090,13 +1427,6 @@ signal-exit@^4.0.1:
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
simple-swizzle@^0.2.2:
version "0.2.2"
resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz"
integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==
dependencies:
is-arrayish "^0.3.1"
sorcery@^0.11.0:
version "0.11.1"
resolved "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz"
@@ -1213,12 +1543,72 @@ svelte-preprocess@^5.0.0, svelte-preprocess@^5.1.3:
sorcery "^0.11.0"
strip-indent "^3.0.0"
"svelte@^3.19.0 || ^4.0.0", "svelte@^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", svelte@^3.54.0, "svelte@^3.54.0 || ^4.0.0", "svelte@^3.54.0 || ^4.0.0 || ^5.0.0-next.0", "svelte@^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0":
svelte@^3.54.0:
version "3.59.2"
resolved "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz"
integrity sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==
tailwindcss@^3, tailwindcss@^3.3.0:
svg.draggable.js@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz#c514a2f1405efb6f0263e7958f5b68fce50603ba"
integrity sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==
dependencies:
svg.js "^2.0.1"
svg.easing.js@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/svg.easing.js/-/svg.easing.js-2.0.0.tgz#8aa9946b0a8e27857a5c40a10eba4091e5691f12"
integrity sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==
dependencies:
svg.js ">=2.3.x"
svg.filter.js@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/svg.filter.js/-/svg.filter.js-2.0.2.tgz#91008e151389dd9230779fcbe6e2c9a362d1c203"
integrity sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==
dependencies:
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:
version "2.7.1"
resolved "https://registry.yarnpkg.com/svg.js/-/svg.js-2.7.1.tgz#eb977ed4737001eab859949b4a398ee1bb79948d"
integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==
svg.pathmorphing.js@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz#c25718a1cc7c36e852ecabc380e758ac09bb2b65"
integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==
dependencies:
svg.js "^2.4.0"
svg.resize.js@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/svg.resize.js/-/svg.resize.js-1.4.3.tgz#885abd248e0cd205b36b973c4b578b9a36f23332"
integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==
dependencies:
svg.js "^2.6.5"
svg.select.js "^2.1.2"
svg.select.js@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-2.1.2.tgz#e41ce13b1acff43a7441f9f8be87a2319c87be73"
integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==
dependencies:
svg.js "^2.2.5"
svg.select.js@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-3.0.1.tgz#a4198e359f3825739226415f82176a90ea5cc917"
integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==
dependencies:
svg.js "^2.6.5"
tailwind-merge@^2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.2.tgz#000f05a703058f9f9f3829c644235f81d4c08a1f"
integrity sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==
tailwindcss@^3.3.0:
version "3.4.10"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz"
integrity sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==
@@ -1246,6 +1636,13 @@ tailwindcss@^3, tailwindcss@^3.3.0:
resolve "^1.22.2"
sucrase "^3.32.0"
text-segmentation@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
dependencies:
utrie "^1.0.2"
thenify-all@^1.0.0:
version "1.6.0"
resolved "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz"
@@ -1272,7 +1669,7 @@ ts-interface-checker@^0.1.9:
resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
ts-node@^10.9.1, ts-node@>=9.0.0:
ts-node@^10.9.1:
version "10.9.2"
resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz"
integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==
@@ -1296,7 +1693,7 @@ tslib@^2.4.1:
resolved "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz"
integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==
typescript@^4.6.4, typescript@>=2.7, "typescript@>=3.9.5 || ^4.0.0 || ^5.0.0":
typescript@^4.6.4:
version "4.9.5"
resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
@@ -1311,6 +1708,11 @@ undici-types@~5.26.4:
resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz"
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:
version "1.1.0"
resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz"
@@ -1324,12 +1726,19 @@ util-deprecate@^1.0.2:
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
utrie@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
dependencies:
base64-arraybuffer "^1.0.2"
v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz"
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
"vite@^3.0.0 || ^4.0.0 || ^5.0.0", vite@^4.0.0:
vite@^4.0.0:
version "4.5.3"
resolved "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz"
integrity sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==