This commit is contained in:
Xinrea
2023-04-08 15:33:41 +08:00
parent 77b85a29b9
commit de7ce9a65a
44 changed files with 7691 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

7
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"svelte.svelte-vscode",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<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>
</body>
</html>

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "bili-shadowreplay",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^1.2.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.0.0",
"@tauri-apps/cli": "^1.2.2",
"@tsconfig/svelte": "^3.0.0",
"@types/node": "^18.7.10",
"autoprefixer": "^10.4.14",
"daisyui": "^2.51.5",
"postcss": "^8.4.21",
"svelte": "^3.54.0",
"svelte-check": "^3.0.0",
"svelte-preprocess": "^5.0.0",
"tailwindcss": "^3.3.0",
"ts-node": "^10.9.1",
"tslib": "^2.4.1",
"typescript": "^4.6.4",
"vite": "^4.0.0"
}
}

3
postcss.config.cjs Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
plugins: [require('tailwindcss'), require('autoprefixer')],
};

1
public/svelte.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

6
public/tauri.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

5
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# Generated by Cargo
# will have compiled files and executables
/target/
tmps
clips

4397
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,36 @@
[package]
name = "bili-shadowreplay"
version = "0.0.0"
description = "A Tauri App"
authors = ["Xinrea"]
license = ""
repository = ""
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
[dependencies]
tauri = { version = "1.2", features = ["dialog-all", "fs-all", "http-all", "protocol-asset", "shell-open", "system-tray"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["blocking", "json"] }
serde_derive = "1.0.158"
serde = "1.0.158"
m3u8-rs = "5.0.3"
async-std = "1.12.0"
futures = "0.3.27"
ffmpeg-sidecar = "0.3.3"
sqlite = "0.30.4"
chrono = "0.4.24"
toml = "0.7.3"
custom_error = "1.9.2"
felgens = "0.3.1"
regex = "1.7.3"
tokio = "1.27.0"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

5
src-tauri/Conf.toml Normal file
View File

@@ -0,0 +1,5 @@
rooms = [13308358, 843610]
admin_uid = [475210]
max_len = 600
cache = "tmps/"
output = "clips/"

3
src-tauri/build.rs Normal file
View File

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

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

379
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,379 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod recorder;
use custom_error::custom_error;
use std::collections::HashMap;
use std::process::Command;
use std::sync::{Arc, Mutex};
use recorder::BiliRecorder;
use tauri::{CustomMenuItem, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem};
use tauri::{Manager, WindowEvent};
custom_error! {StateError
RecorderAlreadyExists = "Recorder already exists",
RecorderCreateError = "Recorder create error",
}
#[tauri::command]
fn show_in_folder(path: String) {
#[cfg(target_os = "windows")]
{
Command::new("explorer")
.args(["/select,", &path]) // The comma after select is not a typo
.spawn()
.unwrap();
}
#[cfg(target_os = "linux")]
{
if path.contains(",") {
// see https://gitlab.freedesktop.org/dbus/dbus/-/issues/76
let new_path = match metadata(&path).unwrap().is_dir() {
true => path,
false => {
let mut path2 = PathBuf::from(path);
path2.pop();
path2.into_os_string().into_string().unwrap()
}
};
Command::new("xdg-open").arg(&new_path).spawn().unwrap();
} else {
Command::new("dbus-send")
.args([
"--session",
"--dest=org.freedesktop.FileManager1",
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
format!("array:string:\"file://{path}\"").as_str(),
"string:\"\"",
])
.spawn()
.unwrap();
}
}
#[cfg(target_os = "macos")]
{
Command::new("open").args(["-R", &path]).spawn().unwrap();
}
}
#[derive(serde::Deserialize, serde::Serialize, Clone)]
pub struct Config {
rooms: Vec<u64>,
admin_uid: Vec<u64>,
max_len: u64,
cache: String,
output: String,
}
const DEFAULT_CONFIG_PATH: &str = "Conf.toml";
impl Config {
pub fn load() -> Self {
if let Ok(content) = std::fs::read_to_string(DEFAULT_CONFIG_PATH) {
if let Ok(config) = toml::from_str(&content) {
return config;
}
}
let config = Config {
rooms: Vec::new(),
admin_uid: Vec::new(),
max_len: 300,
cache: "tmp/".to_string(),
output: "clip/".to_string(),
};
config.save();
config
}
pub fn save(&self) {
let content = toml::to_string(&self).unwrap();
std::fs::write("Conf.toml", content).unwrap();
}
pub fn add(&mut self, room: u64) {
self.rooms.push(room);
self.save();
}
pub fn remove(&mut self, room: u64) {
self.rooms.retain(|&x| x != room);
self.save();
}
pub fn set_admins(&mut self, admins: Vec<u64>) {
self.admin_uid = admins;
self.save();
}
pub fn set_cache_path(&mut self, path: &str) {
// Copy all files in cache to new cache
let old_cache = self.cache.clone();
copy_dir_all(old_cache, path).unwrap();
self.cache = path.to_string();
self.save();
}
pub fn set_output_path(&mut self, path: String) {
self.output = path;
self.save();
}
pub fn set_max_len(&mut self, mut len: u64) {
if len < 30 {
len = 30;
}
self.max_len = len;
self.save();
}
}
fn copy_dir_all(
src: impl AsRef<std::path::Path>,
dst: impl AsRef<std::path::Path>,
) -> std::io::Result<()> {
std::fs::create_dir_all(&dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
} else {
std::fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
}
}
Ok(())
}
#[derive(Clone)]
struct State {
config: Arc<Mutex<Config>>,
recorders: Arc<Mutex<HashMap<u64, BiliRecorder>>>,
}
#[derive(serde::Deserialize, serde::Serialize, Clone)]
struct Summary {
pub count: usize,
pub rooms: Vec<RoomInfo>,
}
#[derive(serde::Deserialize, serde::Serialize, Clone)]
struct RoomInfo {
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 total_length: f64,
pub max_len: u64,
pub live_status: bool,
}
impl State {
pub fn get_summary(&self) -> Summary {
let recorders = self.recorders.lock().unwrap();
recorders.iter().fold(
Summary {
count: recorders.len(),
rooms: Vec::new(),
},
|mut summary, (_, recorder)| {
let room_info = RoomInfo {
room_id: recorder.room_id,
room_title: recorder.room_title.clone(),
room_cover: recorder.room_cover.clone(),
room_keyframe: recorder.room_keyframe.clone(),
user_id: recorder.user_id,
user_name: recorder.user_name.clone(),
user_sign: recorder.user_sign.clone(),
user_avatar: recorder.user_avatar.clone(),
total_length: *recorder.ts_length.read().unwrap(),
max_len: self.config.lock().unwrap().max_len,
live_status: *recorder.live_status.read().unwrap(),
};
summary.rooms.push(room_info);
summary
},
)
}
pub fn add_recorder(&self, room_id: u64) -> Result<(), StateError> {
let mut recorders = self.recorders.lock().unwrap();
if recorders.get(&room_id).is_some() {
Err(StateError::RecorderAlreadyExists)
} else if let Ok(recorder) = BiliRecorder::new(room_id, self.config.clone()) {
recorder.run();
recorders.insert(room_id, recorder);
Ok(())
} else {
Err(StateError::RecorderCreateError)
}
}
pub fn remove_recorder(&self, room_id: u64) {
let mut recorders = self.recorders.lock().unwrap();
let recorder = recorders.get_mut(&room_id).unwrap();
recorder.stop();
recorders.remove(&room_id);
}
pub fn clip(&self, room_id: u64, len: f64) -> Result<String, String> {
let recorders = self.recorders.lock().unwrap();
let recorder = recorders.get(&room_id).unwrap();
if let Ok(file) = recorder.clip(room_id, len) {
Ok(file)
} else {
Err("Clip error".to_string())
}
}
}
#[tauri::command]
fn get_summary(state: tauri::State<State>) -> Summary {
state.get_summary()
}
#[tauri::command]
fn add_recorder(state: tauri::State<State>, room_id: u64) -> Result<(), String> {
// Config update
let mut config = state.config.lock().unwrap();
if config.rooms.contains(&room_id) {
return Err("Room already exists".to_string());
}
if let Err(e) = state.add_recorder(room_id) {
Err(e.to_string())
} else {
config.add(room_id);
Ok(())
}
}
#[tauri::command]
fn remove_recorder(state: tauri::State<State>, room_id: u64) {
// Config update
let mut config = state.config.lock().unwrap();
config.remove(room_id);
state.remove_recorder(room_id)
}
#[tauri::command]
fn get_config(state: tauri::State<State>) -> Config {
state.config.lock().unwrap().clone()
}
#[tauri::command]
fn set_max_len(state: tauri::State<State>, len: u64) {
let mut config = state.config.lock().unwrap();
config.set_max_len(len);
}
#[tauri::command]
fn set_cache_path(state: tauri::State<State>, cache_path: String) {
let mut config = state.config.lock().unwrap();
let old_cache_path = config.cache.clone();
config.set_cache_path(&cache_path);
drop(config);
// Remove old cache
if old_cache_path != cache_path {
if let Err(e) = std::fs::remove_dir_all(old_cache_path) {
println!("Remove old cache error: {}", e);
}
}
}
#[tauri::command]
fn set_output_path(state: tauri::State<State>, output_path: String) {
let mut config = state.config.lock().unwrap();
config.set_output_path(output_path);
}
#[tauri::command]
fn set_admins(state: tauri::State<State>, admins: Vec<u64>) {
let mut config = state.config.lock().unwrap();
config.set_admins(admins);
}
#[tauri::command]
fn clip(state: tauri::State<State>, room_id: u64, len: f64) -> Result<String, String> {
state.clip(room_id, len)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Setup ffmpeg
ffmpeg_sidecar::download::auto_download().unwrap();
// Setup tray icon
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
let hide = CustomMenuItem::new("hide".to_string(), "Hide");
let tray_menu = SystemTrayMenu::new()
.add_item(quit)
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(hide);
let tray = SystemTray::new().with_menu(tray_menu);
// Setup initial state
let state = State {
config: Arc::new(Mutex::new(Config::load())),
recorders: Arc::new(Mutex::new(HashMap::new())),
};
let conf = Config::load();
for room_id in conf.rooms {
std::fs::remove_dir_all(format!("{}/{}", conf.cache, room_id)).unwrap_or(());
if state.add_recorder(room_id).is_err() {
continue;
}
}
// Tauri part
tauri::Builder::default()
.manage(state)
.system_tray(tray)
.on_window_event(|event| match event.event() {
WindowEvent::CloseRequested { api, .. } => {
event.window().hide().unwrap();
api.prevent_close();
}
_ => {}
})
.invoke_handler(tauri::generate_handler![
get_summary,
add_recorder,
remove_recorder,
get_config,
set_max_len,
set_cache_path,
set_output_path,
set_admins,
clip,
show_in_folder
])
.on_system_tray_event(|app, event| match event {
SystemTrayEvent::LeftClick {
position: _,
size: _,
..
} => {
let window = app.get_window("main").unwrap();
window.show().unwrap();
}
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
"quit" => {
std::process::exit(0);
}
"hide" => {
let window = app.get_window("main").unwrap();
window.hide().unwrap();
}
_ => {}
},
_ => {}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
Ok(())
}

443
src-tauri/src/recorder.rs Normal file
View File

@@ -0,0 +1,443 @@
mod bilibili;
use bilibili::errors::BiliClientError;
use bilibili::BiliClient;
use chrono::prelude::*;
use ffmpeg_sidecar::{
command::FfmpegCommand,
event::{FfmpegEvent, LogLevel},
};
use m3u8_rs::Playlist;
use regex::Regex;
use std::error::Error;
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
use tokio::sync::mpsc::{self, UnboundedReceiver};
use crate::Config;
#[derive(Clone)]
pub struct TsEntry {
pub url: String,
pub sequence: u64,
pub length: f64,
}
#[derive(Clone)]
pub struct BiliRecorder {
client: Arc<BiliClient>,
config: Arc<Mutex<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 m3u8_url: Arc<RwLock<String>>,
pub live_status: Arc<RwLock<bool>>,
pub latest_sequence: Arc<Mutex<u64>>,
pub ts_length: Arc<RwLock<f64>>,
ts_entries: Arc<Mutex<Vec<TsEntry>>>,
quit: Arc<Mutex<bool>>,
header: Arc<RwLock<Option<TsEntry>>>,
stream_type: Arc<RwLock<StreamType>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum StreamType {
TS,
FMP4,
}
impl BiliRecorder {
pub fn new(room_id: u64, config: Arc<Mutex<Config>>) -> Result<Self, Box<dyn Error>> {
let client = BiliClient::new()?;
let room_info = client.get_room_info(room_id)?;
let user_info = client.get_user_info(room_info.user_id)?;
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) {
m3u8_url = index_url;
stream_type = stream_type_now;
}
}
Ok(Self {
client: Arc::new(client),
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,
m3u8_url: Arc::new(RwLock::new(m3u8_url)),
live_status: Arc::new(RwLock::new(live_status)),
latest_sequence: Arc::new(Mutex::new(0)),
ts_length: Arc::new(RwLock::new(0.0)),
ts_entries: Arc::new(Mutex::new(Vec::new())),
quit: Arc::new(Mutex::new(false)),
header: Arc::new(RwLock::new(None)),
stream_type: Arc::new(RwLock::new(stream_type)),
})
}
pub fn reset(&self) {
*self.latest_sequence.lock().unwrap() = 0;
*self.ts_length.write().unwrap() = 0.0;
self.ts_entries.lock().unwrap().clear();
*self.header.write().unwrap() = None;
}
fn check_status(&self) -> bool {
if let Ok(room_info) = self.client.get_room_info(self.room_id) {
let live_status = room_info.live_status == 1;
if live_status && !*self.live_status.read().unwrap() {
// Live status changed from offline to online, reset recorder and then update m3u8 url and stream type.
self.reset();
if let Ok((index_url, stream_type)) = self.client.get_play_url(room_info.room_id) {
self.m3u8_url.write().unwrap().replace_range(.., &index_url);
*self.stream_type.write().unwrap() = stream_type;
}
}
*self.live_status.write().unwrap() = live_status;
live_status
} else {
*self.live_status.write().unwrap() = false;
false
}
}
pub fn run(&self) {
let self_clone = self.clone();
thread::spawn(move || {
while !*self_clone.quit.lock().unwrap() {
if self_clone.check_status() {
// Live status is ok, start recording.
while !*self_clone.quit.lock().unwrap() {
if let Err(e) = self_clone.update_entries() {
println!("update entries error: {}", e);
break;
}
}
}
// Every 10s check live status.
thread::sleep(std::time::Duration::from_secs(10));
}
println!("recording thread {} quit.", self_clone.room_id);
});
// Thread for danmaku
let self_clone = self.clone();
thread::spawn(move || {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
self_clone.danmu().await;
});
});
}
async fn danmu(&self) {
let (tx, rx) = mpsc::unbounded_channel();
let ws = ws_socket_object(tx, self.room_id);
if let Err(e) = tokio::select! {v = ws => v, v = self.recv(self.room_id,rx) => v} {
println!("{}", e);
}
}
async fn recv(
&self,
room: u64,
mut rx: UnboundedReceiver<WsStreamMessageType>,
) -> Result<(), FelgensError> {
while let Some(msg) = rx.recv().await {
if let WsStreamMessageType::DanmuMsg(msg) = msg {
if self.config.lock().unwrap().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) {
println!("clip error: {}", e);
}
}
}
}
}
Ok(())
}
pub fn stop(&self) {
*self.quit.lock().unwrap() = false;
}
fn get_playlist(&self) -> Result<Playlist, BiliClientError> {
let url = self.m3u8_url.read().unwrap().clone();
let mut index_content = self.client.get_index_content(&url)?;
if index_content.contains("Not Found") {
// 404 try another time after update
if self.check_status() {
index_content = self.client.get_index_content(&url)?;
} else {
return Err(BiliClientError::InvalidResponse);
}
}
m3u8_rs::parse_playlist_res(index_content.as_bytes())
.map_err(|_| BiliClientError::InvalidPlaylist)
}
fn get_header_url(&self) -> Result<String, BiliClientError> {
let url = self.m3u8_url.read().unwrap().clone();
let mut index_content = self.client.get_index_content(&url)?;
if index_content.contains("Not Found") {
// 404 try another time after update
if self.check_status() {
index_content = self.client.get_index_content(&url)?;
} else {
return Err(BiliClientError::InvalidResponse);
}
}
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();
}
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": ""
// }
// ]
// }
fn ts_url(&self, ts_url: &String) -> Result<String, BiliClientError> {
// Construct url for ts and fmp4 stream.
match *self.stream_type.read().unwrap() {
StreamType::TS => {
// Get host from m3u8 url
let url = self.m3u8_url.read().unwrap().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)
}
} else {
Err(BiliClientError::InvalidUrl)
}
}
StreamType::FMP4 => {
let url = self.m3u8_url.read().unwrap().clone();
if let Some(prefix_part) = url.strip_suffix("index.m3u8") {
Ok(format!("{}{}", prefix_part, ts_url))
} else {
Err(BiliClientError::InvalidUrl)
}
}
}
}
fn update_entries(&self) -> Result<(), BiliClientError> {
let parsed = self.get_playlist();
// Check header if None
if self.header.read().unwrap().is_none()
&& *self.stream_type.read().unwrap() == StreamType::FMP4
{
// Get url from EXT-X-MAP
let header_url = self.get_header_url()?;
if header_url.is_empty() {
return Err(BiliClientError::InvalidPlaylist);
}
let full_header_url = self.ts_url(&header_url)?;
let header = TsEntry {
url: full_header_url.clone(),
sequence: 0,
length: 0.0,
};
// Download header
if let Err(e) = self.client.download_ts(
&self.config.lock().unwrap().cache,
self.room_id,
&full_header_url,
) {
println!("Error downloading header: {:?}", e);
}
*self.header.write().unwrap() = Some(header);
}
match parsed {
Ok(Playlist::MasterPlaylist(pl)) => println!("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().unwrap() {
sequence += 1;
continue;
}
let mut ts_entry = TsEntry {
url: ts.uri,
sequence,
length: ts.duration as f64,
};
let client = self.client.clone();
let ts_url = self.ts_url(&ts_entry.url)?;
ts_entry.url = ts_url.clone();
if ts_url.is_empty() {
continue;
}
let room_id = self.room_id;
let config = self.config.clone();
handles.push(thread::spawn(move || {
if let Err(e) =
client.download_ts(&config.lock().unwrap().cache, room_id, &ts_url)
{
println!("download ts failed: {}", e);
}
}));
let mut entries = self.ts_entries.lock().unwrap();
entries.push(ts_entry);
*self.latest_sequence.lock().unwrap() = sequence;
let mut total_length = self.ts_length.write().unwrap();
*total_length += ts.duration as f64;
while *total_length > self.config.lock().unwrap().max_len as f64 {
*total_length -= entries[0].length;
if let Err(e) = std::fs::remove_file(
BiliClient::url_to_file_name(
&self.config.lock().unwrap().cache,
room_id,
&entries[0].url,
)
.1,
) {
println!("remove file failed: {}", e);
}
entries.remove(0);
}
sequence += 1;
}
for handle in handles {
if let Err(e) = handle.join() {
println!("download ts failed: {:?}", e);
}
}
}
Err(_) => {
return Err(BiliClientError::InvalidIndex);
}
}
Ok(())
}
pub 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().unwrap().clone();
let entry_copy = self.ts_entries.lock().unwrap().clone();
if entry_copy.is_empty() {
return Err(BiliClientError::EmptyCache);
}
for e in entry_copy.iter().rev() {
let length = e.length;
to_combine.push(e);
if duration <= length {
break;
}
duration -= length;
}
to_combine.reverse();
if *self.stream_type.read().unwrap() == StreamType::FMP4 {
// add header to vec
let header = header_copy.as_ref().unwrap();
to_combine.insert(0, header);
}
let file_list = to_combine.iter().fold("".to_string(), |acc, e| {
acc + &BiliClient::url_to_file_name(&self.config.lock().unwrap().cache, room_id, &e.url)
.1
+ "|"
});
let output_path = self.config.lock().unwrap().output.clone();
std::fs::create_dir_all(&output_path).expect("create clips folder failed");
let file_name = format!(
"{}/[{}]{}_({})_{}.mp4",
output_path,
self.room_id,
self.room_title,
Utc::now().format("%Y-%m-%d-%H-%M-%S"),
d
);
let args = format!("-i concat:{} -c copy {}", file_list, file_name);
FfmpegCommand::new()
.args(args.split(' '))
.spawn()
.unwrap()
.iter()
.unwrap()
.for_each(|e| match e {
FfmpegEvent::Log(LogLevel::Error, e) => println!("Error: {}", e),
FfmpegEvent::Progress(p) => println!("Progress: {}", p.time),
_ => {}
});
Ok(file_name)
}
}

View File

@@ -0,0 +1,356 @@
pub mod errors;
use errors::BiliClientError;
use reqwest::blocking::Client;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::sync::Mutex;
use super::StreamType;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayUrlResponse {
pub code: i64,
pub message: String,
pub ttl: i64,
pub data: Data,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Data {
#[serde(rename = "room_id")]
pub room_id: i64,
#[serde(rename = "short_id")]
pub short_id: i64,
pub uid: i64,
#[serde(rename = "is_hidden")]
pub is_hidden: bool,
#[serde(rename = "is_locked")]
pub is_locked: bool,
#[serde(rename = "is_portrait")]
pub is_portrait: bool,
#[serde(rename = "live_status")]
pub live_status: i64,
#[serde(rename = "hidden_till")]
pub hidden_till: i64,
#[serde(rename = "lock_till")]
pub lock_till: i64,
pub encrypted: bool,
#[serde(rename = "pwd_verified")]
pub pwd_verified: bool,
#[serde(rename = "live_time")]
pub live_time: i64,
#[serde(rename = "room_shield")]
pub room_shield: i64,
#[serde(rename = "all_special_types")]
pub all_special_types: Vec<i64>,
#[serde(rename = "playurl_info")]
pub playurl_info: PlayurlInfo,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayurlInfo {
#[serde(rename = "conf_json")]
pub conf_json: String,
pub playurl: Playurl,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Playurl {
pub cid: i64,
#[serde(rename = "g_qn_desc")]
pub g_qn_desc: Vec<GQnDesc>,
pub stream: Vec<Stream>,
#[serde(rename = "p2p_data")]
pub p2p_data: P2pData,
#[serde(rename = "dolby_qn")]
pub dolby_qn: Value,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GQnDesc {
pub qn: i64,
pub desc: String,
#[serde(rename = "hdr_desc")]
pub hdr_desc: String,
#[serde(rename = "attr_desc")]
pub attr_desc: Value,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Stream {
#[serde(rename = "protocol_name")]
pub protocol_name: String,
pub format: Vec<Format>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Format {
#[serde(rename = "format_name")]
pub format_name: String,
pub codec: Vec<Codec>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Codec {
#[serde(rename = "codec_name")]
pub codec_name: String,
#[serde(rename = "current_qn")]
pub current_qn: i64,
#[serde(rename = "accept_qn")]
pub accept_qn: Vec<i64>,
#[serde(rename = "base_url")]
pub base_url: String,
#[serde(rename = "url_info")]
pub url_info: Vec<UrlInfo>,
#[serde(rename = "hdr_qn")]
pub hdr_qn: Value,
#[serde(rename = "dolby_type")]
pub dolby_type: i64,
#[serde(rename = "attr_name")]
pub attr_name: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UrlInfo {
pub host: String,
pub extra: String,
#[serde(rename = "stream_ttl")]
pub stream_ttl: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct P2pData {
pub p2p: bool,
#[serde(rename = "p2p_type")]
pub p2p_type: i64,
#[serde(rename = "m_p2p")]
pub m_p2p: bool,
#[serde(rename = "m_servers")]
pub m_servers: Value,
}
pub struct BiliClient {
client: Client,
headers: reqwest::header::HeaderMap,
extra: Mutex<String>,
}
#[derive(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,
}
#[derive(Debug)]
pub struct UserInfo {
pub user_id: u64,
pub user_name: String,
pub user_sign: String,
pub user_avatar_url: String,
}
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()
.redirect(reqwest::redirect::Policy::none())
.build()
{
Ok(BiliClient {
client,
headers,
extra: Mutex::new("".into()),
})
} else {
Err(BiliClientError::InitClientError)
}
}
pub fn get_user_info(&self, user_id: u64) -> Result<UserInfo, BiliClientError> {
let res: serde_json::Value = self
.client
.get(format!(
"https://api.bilibili.com/x/space/wbi/acc/info?mid={}",
user_id
))
.headers(self.headers.clone())
.send()?
.json()?;
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(),
})
}
pub fn get_room_info(&self, room_id: u64) -> Result<RoomInfo, BiliClientError> {
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()?
.json()?;
let room_id = res["data"]["room_id"]
.as_u64()
.ok_or(BiliClientError::InvalidValue)?;
let room_title = res["data"]["title"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string();
let room_cover_url = res["data"]["user_cover"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string();
let room_keyframe_url = res["data"]["keyframe"]
.as_str()
.ok_or(BiliClientError::InvalidValue)?
.to_string();
let user_id = res["data"]["uid"]
.as_u64()
.ok_or(BiliClientError::InvalidValue)?;
let live_status = res["data"]["live_status"]
.as_u64()
.ok_or(BiliClientError::InvalidValue)? as u8;
Ok(RoomInfo {
room_id,
room_title,
room_cover_url,
room_keyframe_url,
user_id,
live_status,
})
}
pub fn get_play_url(&self, room_id: u64) -> Result<(String, StreamType), BiliClientError> {
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())
.send()?
.json()?;
if res.code == 0 {
if let Some(stream) = res.data.playurl_info.playurl.stream.get(0) {
// Get fmp4 format
if let Some(format) = stream.format.get(1) {
self.get_url_from_format(format)
.ok_or(BiliClientError::InvalidFormat)
.map(|url| (url, StreamType::FMP4))
} else if let Some(format) = stream.format.get(0) {
self.get_url_from_format(format)
.ok_or(BiliClientError::InvalidFormat)
.map(|url| (url, StreamType::TS))
} else {
Err(BiliClientError::InvalidResponse)
}
} else {
Err(BiliClientError::InvalidResponse)
}
} else {
Err(BiliClientError::InvalidResponse)
}
}
fn get_url_from_format(&self, format: &Format) -> Option<String> {
if let Some(codec) = format.codec.get(0) {
if let Some(url_info) = codec.url_info.get(0) {
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;
Some(url)
} else {
None
}
} else {
None
}
}
pub fn get_index_content(&self, url: &String) -> Result<String, BiliClientError> {
Ok(self
.client
.get(url.to_owned() + self.extra.lock().unwrap().as_str())
.headers(self.headers.clone())
.send()?
.text()?)
}
pub 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()?;
let mut file = std::fs::File::create(file_name).unwrap();
let mut content = std::io::Cursor::new(res.bytes()?);
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)
}
}

View File

@@ -0,0 +1,19 @@
use custom_error::custom_error;
custom_error! {pub BiliClientError
InvalidResponse = "Invalid response",
InitClientError = "Client init error",
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",
}
impl From<reqwest::Error> for BiliClientError {
fn from(e: reqwest::Error) -> Self {
BiliClientError::ClientError { err: e }
}
}

73
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,73 @@
{
"build": {
"beforeDevCommand": "yarn dev",
"beforeBuildCommand": "yarn build",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "bili-shadowreplay",
"version": "0.0.1"
},
"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"
},
"security": {
"csp": null
},
"updater": {
"active": false
},
"windows": [
{
"fullscreen": false,
"resizable": true,
"title": "BiliBili ShadowReplay",
"width": 800,
"height": 600,
"theme": "Light"
}
]
}
}

12
src/App.svelte Normal file
View File

@@ -0,0 +1,12 @@
<script lang="ts">
import RoomList from "./lib/RoomList.svelte";
</script>
<main class="px-24 py-12">
<div class="row">
<RoomList />
</div>
</main>
<style>
</style>

564
src/lib/RoomList.svelte Normal file
View File

@@ -0,0 +1,564 @@
<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 { update_await_block_branch } from "svelte/internal";
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() {
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: "",
};
interface Config {
admin_uid: number[];
cache: string;
max_len: number;
output: 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(",");
}
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)),
});
}
</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="设置">
<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">
<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>

8
src/main.ts Normal file
View File

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

9
src/styles.css Normal file
View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
height: 100%;
width: 100%;
}

2
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

9
tailwind.config.cjs Normal file
View File

@@ -0,0 +1,9 @@
module.exports = {
daisyui: {
themes: [
"aqua"
],
},
content: ['./src/**/*.{svelte,js,ts}'],
plugins: [require('daisyui')],
};

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"baseUrl": ".",
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

8
tsconfig.node.json Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node"
},
"include": ["vite.config.ts"]
}

40
vite.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import sveltePreprocess from "svelte-preprocess";
const mobile =
process.env.TAURI_PLATFORM === "android" ||
process.env.TAURI_PLATFORM === "ios";
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [
svelte({
preprocess: [
sveltePreprocess({
typescript: true,
}),
],
}),
],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors
clearScreen: false,
// tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ["VITE_", "TAURI_"],
build: {
// Tauri supports es2021
target: process.env.TAURI_PLATFORM == "windows" ? "chrome105" : "safari13",
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
},
}));

1211
yarn.lock Normal file

File diff suppressed because it is too large Load Diff