Compare commits

...

5 Commits

Author SHA1 Message Date
Xinrea
87525419a2 release v0.0.5 2023-05-25 17:43:21 +08:00
Xinrea
d427d32463 fix: output path with spaces 2023-05-25 17:16:08 +08:00
Xinrea
0881dfdc3f release v0.0.4 2023-05-25 00:34:51 +08:00
Xinrea
a6f369735a fix: add sign for user info api 2023-05-25 00:32:55 +08:00
Xinrea
4d9ae8a272 fix windows path & update doc 2023-04-08 17:42:00 +08:00
17 changed files with 4936 additions and 968 deletions

View File

@@ -1,8 +1,26 @@
# Tauri + Svelte + Typescript
# Bilibili ShadowReplay
This template should help get you started developing with Tauri, Svelte and TypeScript in Vite.
![主界面](doc/main.png)
## 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站直播的工具可以将直播的视频缓存到本地便于及时保存回放方便后期剪辑工作。
![clip](doc/clip.png)
除了在界面上手动操作外,还可以通过弹幕触发切片。
> 只有管理员UID设置中的用户才能触发切片
![弹幕](doc/danmu_command.png)
## 设置
![设置](doc/setting.png)
- `缓存时长`:缓存的视频时长,单位为秒
- `缓存目录`:缓存的视频存放目录
- `切片目录`: 切片的视频存放目录
- `管理员UID`B站的UID用于判断是否有权限在直播间通过弹幕触发切片可设置多个使用英文逗号分隔。

BIN
doc/clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

BIN
doc/danmu_command.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
doc/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
doc/output.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
doc/setting.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

3936
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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
View File

@@ -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"

View File

@@ -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

View File

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

View File

@@ -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;
}
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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": {

View File

@@ -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

1694
yarn.lock

File diff suppressed because it is too large Load Diff