init
24
.gitignore
vendored
Normal 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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
16
index.html
Normal 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
@@ -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
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: [require('tailwindcss'), require('autoprefixer')],
|
||||
};
|
||||
1
public/svelte.svg
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
tmps
|
||||
clips
|
||||
4397
src-tauri/Cargo.lock
generated
Normal file
36
src-tauri/Cargo.toml
Normal 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
@@ -0,0 +1,5 @@
|
||||
rooms = [13308358, 843610]
|
||||
admin_uid = [475210]
|
||||
max_len = 600
|
||||
cache = "tmps/"
|
||||
output = "clips/"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
379
src-tauri/src/main.rs
Normal 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
@@ -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)
|
||||
}
|
||||
}
|
||||
356
src-tauri/src/recorder/bilibili.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
19
src-tauri/src/recorder/bilibili/errors.rs
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
9
tailwind.config.cjs
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
daisyui: {
|
||||
themes: [
|
||||
"aqua"
|
||||
],
|
||||
},
|
||||
content: ['./src/**/*.{svelte,js,ts}'],
|
||||
plugins: [require('daisyui')],
|
||||
};
|
||||
21
tsconfig.json
Normal 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
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
40
vite.config.ts
Normal 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,
|
||||
},
|
||||
}));
|
||||