mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-25 12:29:24 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87525419a2 | ||
|
|
d427d32463 | ||
|
|
0881dfdc3f | ||
|
|
a6f369735a | ||
|
|
4d9ae8a272 |
28
README.md
28
README.md
@@ -1,8 +1,26 @@
|
||||
# Tauri + Svelte + Typescript
|
||||
# Bilibili ShadowReplay
|
||||
|
||||
This template should help get you started developing with Tauri, Svelte and TypeScript in Vite.
|
||||

|
||||
|
||||
## Recommended IDE Setup
|
||||
> 点击关闭后程序仍会在后台运行,请找到托盘区的图标右键退出程序
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
||||
# bili-shadowreplay
|
||||
## 介绍
|
||||
|
||||
Bilibili ShadowReplay 是一个用于缓存B站直播的工具,可以将直播的视频缓存到本地,便于及时保存回放,方便后期剪辑工作。
|
||||
|
||||

|
||||
|
||||
除了在界面上手动操作外,还可以通过弹幕触发切片。
|
||||
|
||||
> 只有管理员UID设置中的用户才能触发切片
|
||||
|
||||

|
||||
|
||||
## 设置
|
||||
|
||||

|
||||
|
||||
- `缓存时长`:缓存的视频时长,单位为秒
|
||||
- `缓存目录`:缓存的视频存放目录
|
||||
- `切片目录`: 切片的视频存放目录
|
||||
- `管理员UID`:B站的UID,用于判断是否有权限在直播间通过弹幕触发切片;可设置多个,使用英文逗号分隔。
|
||||
BIN
doc/clip.png
Normal file
BIN
doc/clip.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
BIN
doc/danmu_command.png
Normal file
BIN
doc/danmu_command.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
doc/main.png
Normal file
BIN
doc/main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
BIN
doc/output.png
Normal file
BIN
doc/output.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
doc/setting.png
Normal file
BIN
doc/setting.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
3936
package-lock.json
generated
Normal file
3936
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bili-shadowreplay",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
49
src-tauri/Cargo.lock
generated
49
src-tauri/Cargo.lock
generated
@@ -214,7 +214,7 @@ checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a"
|
||||
|
||||
[[package]]
|
||||
name = "bili-shadowreplay"
|
||||
version = "0.0.0"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"async-std",
|
||||
"chrono",
|
||||
@@ -223,6 +223,9 @@ dependencies = [
|
||||
"ffmpeg-sidecar",
|
||||
"futures",
|
||||
"m3u8-rs",
|
||||
"md5",
|
||||
"pct-str",
|
||||
"platform-dirs",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -725,6 +728,16 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-next"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf36e65a80337bea855cd4ef9b8401ffce06a7baedf2e85ec467b1ac3f6e82b6"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dirs-sys-next",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-next"
|
||||
version = "2.0.0"
|
||||
@@ -1881,6 +1894,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.5.0"
|
||||
@@ -2240,6 +2259,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
|
||||
|
||||
[[package]]
|
||||
name = "pct-str"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77d207ec8d182c2fef45f028b9b9507770df19e89b3e14827ccd95d4a23f6003"
|
||||
dependencies = [
|
||||
"utf8-decode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.2.0"
|
||||
@@ -2362,6 +2390,15 @@ version = "0.3.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160"
|
||||
|
||||
[[package]]
|
||||
name = "platform-dirs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e188d043c1a692985f78b5464853a263f1a27e5bd6322bad3a4078ee3c998a38"
|
||||
dependencies = [
|
||||
"dirs-next 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.4.3"
|
||||
@@ -3182,7 +3219,7 @@ dependencies = [
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dirs-next",
|
||||
"dirs-next 2.0.0",
|
||||
"dispatch",
|
||||
"gdk",
|
||||
"gdk-pixbuf",
|
||||
@@ -3237,7 +3274,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"attohttpc",
|
||||
"cocoa",
|
||||
"dirs-next",
|
||||
"dirs-next 2.0.0",
|
||||
"embed_plist",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
@@ -3792,6 +3829,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-decode"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca61eb27fa339aa08826a29f03e87b99b4d8f0fc2255306fd266bb1b6a9de498"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bili-shadowreplay"
|
||||
version = "0.0.0"
|
||||
version = "0.0.3"
|
||||
description = "A Tauri App"
|
||||
authors = ["Xinrea"]
|
||||
license = ""
|
||||
@@ -29,6 +29,9 @@ custom_error = "1.9.2"
|
||||
felgens = "0.3.1"
|
||||
regex = "1.7.3"
|
||||
tokio = "1.27.0"
|
||||
platform-dirs = "0.3.0"
|
||||
pct-str = "1.2.0"
|
||||
md5 = "0.7.0"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
rooms = [13308358, 843610]
|
||||
admin_uid = [475210]
|
||||
max_len = 600
|
||||
cache = "tmps/"
|
||||
output = "clips/"
|
||||
@@ -12,6 +12,8 @@ use std::sync::{Arc, Mutex};
|
||||
use tauri::{CustomMenuItem, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem};
|
||||
use tauri::{Manager, WindowEvent};
|
||||
|
||||
use platform_dirs::AppDirs;
|
||||
|
||||
custom_error! {StateError
|
||||
RecorderAlreadyExists = "Recorder already exists",
|
||||
RecorderCreateError = "Recorder create error",
|
||||
@@ -73,11 +75,11 @@ pub struct Config {
|
||||
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) {
|
||||
let app_dirs = AppDirs::new(Some("bili-shadowreplay"), false).unwrap();
|
||||
let config_path = app_dirs.config_dir.join("Conf.toml");
|
||||
if let Ok(content) = std::fs::read_to_string(config_path) {
|
||||
if let Ok(config) = toml::from_str(&content) {
|
||||
return config;
|
||||
}
|
||||
@@ -86,8 +88,18 @@ impl Config {
|
||||
rooms: Vec::new(),
|
||||
admin_uid: Vec::new(),
|
||||
max_len: 300,
|
||||
cache: "tmp/".to_string(),
|
||||
output: "clip/".to_string(),
|
||||
cache: app_dirs
|
||||
.cache_dir
|
||||
.join("cache")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
output: app_dirs
|
||||
.data_dir
|
||||
.join("output")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
};
|
||||
config.save();
|
||||
config
|
||||
@@ -95,7 +107,11 @@ impl Config {
|
||||
|
||||
pub fn save(&self) {
|
||||
let content = toml::to_string(&self).unwrap();
|
||||
std::fs::write("Conf.toml", content).unwrap();
|
||||
let app_dirs = AppDirs::new(Some("bili-shadowreplay"), false).unwrap();
|
||||
// Create app dirs if not exists
|
||||
std::fs::create_dir_all(&app_dirs.config_dir).unwrap();
|
||||
let config_path = app_dirs.config_dir.join("Conf.toml");
|
||||
std::fs::write(config_path, content).unwrap();
|
||||
}
|
||||
|
||||
pub fn add(&mut self, room: u64) {
|
||||
@@ -115,6 +131,9 @@ impl Config {
|
||||
|
||||
pub fn set_cache_path(&mut self, path: &str) {
|
||||
// Copy all files in cache to new cache
|
||||
if self.cache == path {
|
||||
return;
|
||||
}
|
||||
let old_cache = self.cache.clone();
|
||||
copy_dir_all(old_cache, path).unwrap();
|
||||
self.cache = path.to_string();
|
||||
@@ -202,6 +221,7 @@ impl State {
|
||||
live_status: *recorder.live_status.read().unwrap(),
|
||||
};
|
||||
summary.rooms.push(room_info);
|
||||
summary.rooms.sort_by(|a, b| a.room_id.cmp(&b.room_id));
|
||||
summary
|
||||
},
|
||||
)
|
||||
@@ -210,13 +230,18 @@ impl State {
|
||||
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)
|
||||
return Err(StateError::RecorderAlreadyExists);
|
||||
}
|
||||
match BiliRecorder::new(room_id, self.config.clone()) {
|
||||
Ok(recorder) => {
|
||||
recorder.run();
|
||||
recorders.insert(room_id, recorder);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
println!("create recorder failed: {:?}", e);
|
||||
Err(StateError::RecorderCreateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,8 +264,8 @@ impl State {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_summary(state: tauri::State<State>) -> Summary {
|
||||
state.get_summary()
|
||||
async fn get_summary(state: tauri::State<'_, State>) -> Result<Summary, ()> {
|
||||
Ok(state.get_summary())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -328,6 +353,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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() {
|
||||
println!("Failed to add recorder for room {}", room_id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,13 +99,11 @@ impl BiliRecorder {
|
||||
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;
|
||||
}
|
||||
// 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
|
||||
@@ -426,9 +424,11 @@ impl BiliRecorder {
|
||||
Utc::now().format("%Y-%m-%d-%H-%M-%S"),
|
||||
d
|
||||
);
|
||||
let args = format!("-i concat:{} -c copy {}", file_list, file_name);
|
||||
println!("{}", file_name);
|
||||
let args = format!("-i concat:{} -c copy", file_list);
|
||||
FfmpegCommand::new()
|
||||
.args(args.split(' '))
|
||||
.output(file_name.clone())
|
||||
.spawn()
|
||||
.unwrap()
|
||||
.iter()
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
pub mod errors;
|
||||
use errors::BiliClientError;
|
||||
use pct_str::PctString;
|
||||
use pct_str::URIReserved;
|
||||
use regex::Regex;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use super::StreamType;
|
||||
|
||||
@@ -204,11 +209,18 @@ impl BiliClient {
|
||||
}
|
||||
|
||||
pub fn get_user_info(&self, user_id: u64) -> Result<UserInfo, BiliClientError> {
|
||||
let params: Value = json!({
|
||||
"mid": user_id.to_string(),
|
||||
"platform": "web",
|
||||
"web_location": "1550101",
|
||||
"token": ""
|
||||
});
|
||||
let params = self.get_sign(params)?;
|
||||
let res: serde_json::Value = self
|
||||
.client
|
||||
.get(format!(
|
||||
"https://api.bilibili.com/x/space/wbi/acc/info?mid={}",
|
||||
user_id
|
||||
"https://api.bilibili.com/x/space/wbi/acc/info?{}",
|
||||
params
|
||||
))
|
||||
.headers(self.headers.clone())
|
||||
.send()?
|
||||
@@ -353,4 +365,82 @@ impl BiliClient {
|
||||
let full_file = tmp_path.clone() + file_name.split('?').collect::<Vec<&str>>()[0];
|
||||
(tmp_path, full_file)
|
||||
}
|
||||
|
||||
// Method from js code
|
||||
pub fn get_sign(&self, mut parameters: Value) -> Result<String, BiliClientError> {
|
||||
let table = vec![
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42,
|
||||
19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60,
|
||||
51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
|
||||
];
|
||||
let nav_info: Value = self
|
||||
.client
|
||||
.get("https://api.bilibili.com/x/web-interface/nav")
|
||||
.headers(self.headers.clone())
|
||||
.send()?
|
||||
.json()?;
|
||||
let re = Regex::new(r"wbi/(.*).png").unwrap();
|
||||
let img = re
|
||||
.captures(nav_info["data"]["wbi_img"]["img_url"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.as_str();
|
||||
let sub = re
|
||||
.captures(nav_info["data"]["wbi_img"]["sub_url"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.as_str();
|
||||
let raw_string = format!("{}{}", img, sub);
|
||||
let mut encoded = Vec::new();
|
||||
table.into_iter().for_each(|x| {
|
||||
if x < raw_string.len() {
|
||||
encoded.push(raw_string.as_bytes()[x]);
|
||||
}
|
||||
});
|
||||
// only keep 32 bytes of encoded
|
||||
encoded = encoded[0..32].to_vec();
|
||||
let encoded = String::from_utf8(encoded).unwrap();
|
||||
// Timestamp in seconds
|
||||
let wts = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
parameters
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("wts".to_owned(), serde_json::Value::String(wts.to_string()));
|
||||
// Get all keys from parameters into vec
|
||||
let mut keys = parameters
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(|x| x.to_owned())
|
||||
.collect::<Vec<String>>();
|
||||
// sort keys
|
||||
keys.sort();
|
||||
let mut params = String::new();
|
||||
keys.iter().for_each(|x| {
|
||||
params.push_str(x);
|
||||
params.push('=');
|
||||
// Value filters !'()* characters
|
||||
let value = parameters
|
||||
.get(x)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.replace(['!', '\'', '(', ')', '*'], "");
|
||||
let value = PctString::encode(value.chars(), URIReserved);
|
||||
params.push_str(value.as_str());
|
||||
// add & if not last
|
||||
if x != keys.last().unwrap() {
|
||||
params.push('&');
|
||||
}
|
||||
});
|
||||
// md5 params+encoded
|
||||
let w_rid = md5::compute(params.to_string() + encoded.as_str());
|
||||
let params = params + format!("&w_rid={:x}", w_rid).as_str();
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"build": {
|
||||
"beforeDevCommand": "yarn dev",
|
||||
"beforeBuildCommand": "yarn build",
|
||||
"devPath": "http://localhost:1420",
|
||||
"devPath": "http://localhost:8053",
|
||||
"distDir": "../dist",
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"package": {
|
||||
"productName": "bili-shadowreplay",
|
||||
"version": "0.0.1"
|
||||
"version": "0.0.5"
|
||||
},
|
||||
"tauri": {
|
||||
"systemTray": {
|
||||
@@ -24,7 +24,10 @@
|
||||
"http": {
|
||||
"all": true,
|
||||
"request": true,
|
||||
"scope": ["https://**", "http://**"]
|
||||
"scope": [
|
||||
"https://**",
|
||||
"http://**"
|
||||
]
|
||||
},
|
||||
"dialog": {
|
||||
"all": true,
|
||||
@@ -34,11 +37,15 @@
|
||||
"protocol": {
|
||||
"all": false,
|
||||
"asset": true,
|
||||
"assetScope": ["**"]
|
||||
"assetScope": [
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"fs": {
|
||||
"all": true,
|
||||
"scope": ["**"]
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -23,7 +23,7 @@ export default defineConfig(async () => ({
|
||||
clearScreen: false,
|
||||
// tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
port: 8053,
|
||||
strictPort: true,
|
||||
},
|
||||
// to make use of `TAURI_DEBUG` and other env variables
|
||||
|
||||
Reference in New Issue
Block a user