Compare commits

...

22 Commits

Author SHA1 Message Date
Xinrea
06de32ffe7 bump version to 2.5.9 2025-05-27 15:13:45 +08:00
Xinrea
dd43074e46 fix: danmu api missing param 2025-05-27 15:13:22 +08:00
Xinrea
93495e13db bump version to 2.5.8 2025-05-27 01:49:02 +08:00
Xinrea
16950edae4 fix: danmu api wbi_sign required 2025-05-27 01:48:38 +08:00
Xinrea
4af1203360 fix: get logfile size on unix and windows 2025-05-24 18:04:51 +08:00
Xinrea
55b5bd1fd2 fix: metadata on windows 2025-05-24 17:00:09 +08:00
Xinrea
f0a7cf4ed0 bump version to 2.5.7 2025-05-24 15:55:02 +08:00
Xinrea
62e7412abf feat: new stream recording control 2025-05-24 15:54:06 +08:00
Xinrea
275bf647d2 feat: add fail count to avoid connection reject 2025-05-24 15:15:28 +08:00
Xinrea
00af723be9 refactor: stream update 2025-05-24 14:05:23 +08:00
Xinrea
19da577836 chore: add logs for recorder control handler 2025-05-24 12:57:24 +08:00
Xinrea
bf3a2b469b feat: implement log rotation on startup 2025-05-20 23:15:37 +08:00
Xinrea
bf31bfd099 feat: add links on release list 2025-05-20 22:26:47 +08:00
Xinrea
d02fea99f2 refactor: sidebar items 2025-05-20 22:17:49 +08:00
Xinrea
2404bacb4e fix: force switching to new stream when error (close #106) 2025-05-15 14:54:01 +08:00
Xinrea
b6c274c181 fix: adjust date-time-adding rule in manifest 2025-05-15 14:43:47 +08:00
Xinrea
f9b472aee7 fix: wrong ts comparison when clip (close #105) 2025-05-15 14:43:31 +08:00
Xinrea
45f277741b fix: entry timestamp to date str 2025-05-15 01:28:00 +08:00
Xinrea
94179f59cd bump version to 2.5.6 2025-05-15 01:07:37 +08:00
Xinrea
c7b550a3e3 fix: stuck when clipping douyin live 2025-05-15 01:07:36 +08:00
Xinrea
fd51fd2387 chore: adjust ffmpeg log level 2025-05-14 16:39:29 +08:00
Xinrea
23d1798ab6 fix: panic on recorder monitor thread 2025-05-14 16:37:47 +08:00
16 changed files with 345 additions and 246 deletions

5
.gitignore vendored
View File

@@ -32,4 +32,7 @@ src-tauri/tests/audio/*.srt
.env
docs/.vitepress/cache
docs/.vitepress/dist
docs/.vitepress/dist
*.debug.js
*.debug.map

View File

@@ -1,53 +1,65 @@
<!DOCTYPE html>
<html lang="zh-cn" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="shaka-player/controls.min.css" />
<link rel="stylesheet" href="shaka-player/youtube-theme.css" />
<script src="shaka-player/shaka-player.ui.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="src/live_main.ts"></script>
<style>
input[type="range"]::-webkit-slider-thumb {
width: 12px; /* 设置滑块按钮宽度 */
height: 12px; /* 设置滑块按钮高度 */
border-radius: 50%; /* 设置为圆形 */
}
html {
scrollbar-face-color: #646464;
scrollbar-base-color: #646464;
scrollbar-3dlight-color: #646464;
scrollbar-highlight-color: #646464;
scrollbar-track-color: #000;
scrollbar-arrow-color: #000;
scrollbar-shadow-color: #646464;
}
::-webkit-scrollbar {
width: 8px;
height: 3px;
}
::-webkit-scrollbar-button {
background-color: #666;
}
::-webkit-scrollbar-track {
background-color: #646464;
}
::-webkit-scrollbar-track-piece {
background-color: #000;
}
::-webkit-scrollbar-thumb {
height: 50px;
background-color: #666;
border-radius: 3px;
}
::-webkit-scrollbar-corner {
background-color: #646464;
}
</style>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="shaka-player/controls.min.css" />
<link rel="stylesheet" href="shaka-player/youtube-theme.css" />
<script src="shaka-player/shaka-player.ui.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="src/live_main.ts"></script>
<style>
input[type="range"]::-webkit-slider-thumb {
width: 12px;
/* 设置滑块按钮宽度 */
height: 12px;
/* 设置滑块按钮高度 */
border-radius: 50%;
/* 设置为圆形 */
}
html {
scrollbar-face-color: #646464;
scrollbar-base-color: #646464;
scrollbar-3dlight-color: #646464;
scrollbar-highlight-color: #646464;
scrollbar-track-color: #000;
scrollbar-arrow-color: #000;
scrollbar-shadow-color: #646464;
}
::-webkit-scrollbar {
width: 8px;
height: 3px;
}
::-webkit-scrollbar-button {
background-color: #666;
}
::-webkit-scrollbar-track {
background-color: #646464;
}
::-webkit-scrollbar-track-piece {
background-color: #000;
}
::-webkit-scrollbar-thumb {
height: 50px;
background-color: #666;
border-radius: 3px;
}
::-webkit-scrollbar-corner {
background-color: #646464;
}
</style>
</body>
</html>

View File

@@ -1,7 +1,7 @@
{
"name": "bili-shadowreplay",
"private": true,
"version": "2.5.5",
"version": "2.5.9",
"type": "module",
"scripts": {
"dev": "vite",

17
src-tauri/Cargo.lock generated
View File

@@ -552,7 +552,7 @@ dependencies = [
"m3u8-rs",
"md5",
"mime_guess",
"pct-str",
"pct-str 1.2.0",
"platform-dirs",
"rand 0.8.5",
"regex",
@@ -1608,12 +1608,15 @@ dependencies = [
[[package]]
name = "felgens"
version = "0.3.1"
source = "git+https://github.com/Xinrea/felgens.git?tag=v0.4.3#828432a7b43af714f1981fafd955cc37d1b42ec1"
source = "git+https://github.com/Xinrea/felgens.git?tag=v0.4.6#6c2a4ff3d8576923bf22818d39a96fac4cb3f33c"
dependencies = [
"brotli 3.5.0",
"flate2",
"futures-util",
"log",
"md5",
"pct-str 2.0.0",
"regex",
"reqwest 0.11.27",
"scroll",
"scroll_derive",
@@ -3808,6 +3811,16 @@ dependencies = [
"utf8-decode",
]
[[package]]
name = "pct-str"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf1bdcc492c285a50bed60860dfa00b50baf1f60c73c7d6b435b01a2a11fd6ff"
dependencies = [
"thiserror 1.0.69",
"utf8-decode",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"

View File

@@ -21,7 +21,7 @@ async-ffmpeg-sidecar = "0.0.1"
chrono = { version = "0.4.24", features = ["serde"] }
toml = "0.7.3"
custom_error = "1.9.2"
felgens = { git = "https://github.com/Xinrea/felgens.git", tag = "v0.4.3" }
felgens = { git = "https://github.com/Xinrea/felgens.git", tag = "v0.4.6" }
regex = "1.7.3"
tokio = { version = "1.27.0", features = ["process"] }
platform-dirs = "0.3.0"

View File

@@ -51,7 +51,7 @@ pub async fn clip_from_m3u8(
}
FfmpegEvent::LogEOF => break,
FfmpegEvent::Log(_level, content) => {
log::debug!("{}", content);
log::info!("{}", content);
}
FfmpegEvent::Error(e) => {
log::error!("Clip error: {}", e);
@@ -107,7 +107,7 @@ pub async fn extract_audio(file: &Path) -> Result<(), String> {
}
FfmpegEvent::LogEOF => break,
FfmpegEvent::Log(_level, content) => {
log::debug!("{}", content);
log::info!("{}", content);
}
_ => {}
}
@@ -195,7 +195,7 @@ pub async fn encode_video_subtitle(
}
FfmpegEvent::LogEOF => break,
FfmpegEvent::Log(_level, content) => {
log::debug!("{}", content);
log::info!("{}", content);
}
_ => {}
}
@@ -282,7 +282,7 @@ pub async fn encode_video_danmu(
.update(format!("压制中:{}", p.time).as_str());
}
FfmpegEvent::Log(_level, content) => {
log::debug!("{}", content);
log::info!("{}", content);
}
FfmpegEvent::LogEOF => break,
_ => {}

View File

@@ -260,6 +260,7 @@ pub async fn set_auto_start(
room_id: u64,
auto_start: bool,
) -> Result<(), String> {
log::info!("Set auto-start for recorder {platform} {room_id} {auto_start}");
let platform = PlatformType::from_str(&platform).unwrap();
state
.recorder_manager
@@ -274,6 +275,7 @@ pub async fn force_start(
platform: String,
room_id: u64,
) -> Result<(), String> {
log::info!("Force start recorder {platform} {room_id}");
let platform = PlatformType::from_str(&platform).unwrap();
state.recorder_manager.force_start(platform, room_id).await;
Ok(())
@@ -285,6 +287,7 @@ pub async fn force_stop(
platform: String,
room_id: u64,
) -> Result<(), String> {
log::info!("Force stop recorder {platform} {room_id}");
let platform = PlatformType::from_str(&platform).unwrap();
state.recorder_manager.force_stop(platform, room_id).await;
Ok(())

View File

@@ -21,6 +21,8 @@ mod subtitle_generator;
mod tray;
use archive_migration::try_rebuild_archives;
use async_std::fs;
use chrono::Utc;
use config::Config;
use database::Database;
use recorder::bilibili::client::BiliClient;
@@ -32,6 +34,12 @@ use std::path::Path;
use std::sync::Arc;
use tokio::sync::RwLock;
#[cfg(not(target_os = "windows"))]
use std::os::unix::fs::MetadataExt;
#[cfg(target_os = "windows")]
use std::os::windows::fs::MetadataExt;
#[cfg(feature = "gui")]
use {
recorder::PlatformType,
@@ -53,16 +61,36 @@ use {
},
};
/// open a log file, if file size exceeds 1MB, backup log file and create a new one.
async fn open_log_file(log_dir: &Path) -> Result<File, Box<dyn std::error::Error>> {
let log_filename = log_dir.join("bsr.log");
if let Ok(meta) = fs::metadata(&log_filename).await {
#[cfg(target_os = "windows")]
let file_size = meta.file_size();
#[cfg(not(target_os = "windows"))]
let file_size = meta.size();
if file_size > 1024 * 1024 {
// move original file to backup
let date_str = Utc::now().format("%Y-%m-%d_%H-%M-%S").to_string();
let backup_filename = log_dir.join(&format!("bsr-{date_str}.log"));
let _ = fs::rename(&log_filename, backup_filename).await?;
}
}
Ok(File::options()
.create(true)
.append(true)
.open(&log_filename)?)
}
async fn setup_logging(log_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
// mkdir if not exists
if !log_dir.exists() {
std::fs::create_dir_all(log_dir)?;
}
let log_file = log_dir.join("bsr.log");
// open file with append mode
let file = File::options().create(true).append(true).open(&log_file)?;
let file = open_log_file(log_dir).await?;
let config = ConfigBuilder::new()
.set_target_level(simplelog::LevelFilter::Debug)
@@ -230,6 +258,12 @@ async fn setup_app_state(app: &tauri::App) -> Result<State, Box<dyn std::error::
let db_clone = db.clone();
let client_clone = client.clone();
let emitter = EventEmitter::new(app.handle().clone());
let binding = dbs.0.read().await;
let dbpool = binding.get("sqlite:data_v2.db").unwrap();
let sqlite_pool = match dbpool {
tauri_plugin_sql::DbPool::Sqlite(pool) => Some(pool),
};
db_clone.set(sqlite_pool.unwrap().clone()).await;
let recorder_manager = Arc::new(RecorderManager::new(
app.app_handle().clone(),
@@ -237,12 +271,6 @@ async fn setup_app_state(app: &tauri::App) -> Result<State, Box<dyn std::error::
db.clone(),
config.clone(),
));
let binding = dbs.0.read().await;
let dbpool = binding.get("sqlite:data_v2.db").unwrap();
let sqlite_pool = match dbpool {
tauri_plugin_sql::DbPool::Sqlite(pool) => Some(pool),
};
db_clone.set(sqlite_pool.unwrap().clone()).await;
let accounts = db_clone.get_accounts().await?;
if accounts.is_empty() {

View File

@@ -3,6 +3,7 @@ pub mod errors;
pub mod profile;
pub mod response;
use super::entry::{EntryStore, Range};
use super::errors::RecorderError;
use super::PlatformType;
use crate::database::account::AccountRow;
use crate::progress_manager::Event;
@@ -53,11 +54,11 @@ pub struct BiliRecorder {
user_info: Arc<RwLock<UserInfo>>,
live_status: Arc<RwLock<bool>>,
live_id: Arc<RwLock<String>>,
manual_stop_id: Arc<RwLock<Option<String>>>,
cover: Arc<RwLock<Option<String>>>,
entry_store: Arc<RwLock<Option<EntryStore>>>,
is_recording: Arc<RwLock<bool>>,
auto_start: Arc<RwLock<bool>>,
current_record: Arc<RwLock<bool>>,
force_update: Arc<AtomicBool>,
last_update: Arc<RwLock<i64>>,
quit: Arc<Mutex<bool>>,
@@ -125,8 +126,8 @@ impl BiliRecorder {
entry_store: Arc::new(RwLock::new(None)),
is_recording: Arc::new(RwLock::new(false)),
auto_start: Arc::new(RwLock::new(options.auto_start)),
current_record: Arc::new(RwLock::new(false)),
live_id: Arc::new(RwLock::new(String::new())),
manual_stop_id: Arc::new(RwLock::new(None)),
cover: Arc::new(RwLock::new(cover)),
last_update: Arc::new(RwLock::new(Utc::now().timestamp())),
force_update: Arc::new(AtomicBool::new(false)),
@@ -152,10 +153,17 @@ impl BiliRecorder {
return false;
}
*self.current_record.read().await
let live_id = self.live_id.read().await.clone();
self.manual_stop_id
.read()
.await
.as_ref()
.is_none_or(|v| v != &live_id)
}
async fn check_status(&self) -> bool {
log::info!("[{}]Check room status", self.room_id);
match self
.client
.read()
@@ -170,10 +178,9 @@ impl BiliRecorder {
// handle live notification
if *self.live_status.read().await != live_status {
log::info!(
"[{}]Live status changed to {}, current_record: {}, auto_start: {}",
"[{}]Live status changed to {}, auto_start: {}",
self.room_id,
live_status,
*self.current_record.read().await,
*self.auto_start.read().await
);
@@ -230,13 +237,12 @@ impl BiliRecorder {
if !live_status {
self.reset().await;
*self.current_record.write().await = false;
return false;
}
// no need to check stream if current_record is false and auto_start is false
if !*self.current_record.read().await && !*self.auto_start.read().await {
// no need to check stream if should not record and auto_start is false
if !self.should_record().await && !*self.auto_start.read().await {
return true;
}
@@ -309,10 +315,7 @@ impl BiliRecorder {
let stream = new_stream.unwrap();
log::info!("[{}]New stream: {:?}", self.room_id, stream);
// auto start must be true here, if what fetched is a new stream, set current_record=true to auto start recording
if self.live_stream.read().await.is_none()
let should_update_stream = self.live_stream.read().await.is_none()
|| !self
.live_stream
.read()
@@ -320,19 +323,18 @@ impl BiliRecorder {
.as_ref()
.unwrap()
.is_same(&stream)
|| self.force_update.load(Ordering::Relaxed)
{
|| self.force_update.load(Ordering::Relaxed);
if should_update_stream {
log::info!(
"[{}]Fetched a new stream: {:?} => {}",
"[{}]Update to a new stream: {:?} => {}",
self.room_id,
self.live_stream.read().await.clone(),
stream
);
*self.current_record.write().await = true;
self.force_update.store(false, Ordering::Relaxed);
}
if *self.current_record.read().await {
self.force_update.store(false, Ordering::Relaxed);
let new_stream = self.fetch_real_stream(stream).await;
if new_stream.is_err() {
log::error!(
@@ -346,8 +348,6 @@ impl BiliRecorder {
let new_stream = new_stream.unwrap();
*self.live_stream.write().await = Some(new_stream);
*self.last_update.write().await = Utc::now().timestamp();
return true;
}
true
@@ -837,6 +837,8 @@ impl super::Recorder for BiliRecorder {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
while !*self_clone.quit.lock().await {
let mut connection_fail_count = 0;
let mut rng = rand::thread_rng();
if self_clone.check_status().await {
// Live status is ok, start recording.
while self_clone.should_record().await {
@@ -855,6 +857,7 @@ impl super::Recorder for BiliRecorder {
);
}
*self_clone.is_recording.write().await = true;
connection_fail_count = 0;
}
Err(e) => {
log::error!(
@@ -862,15 +865,21 @@ impl super::Recorder for BiliRecorder {
self_clone.room_id,
e
);
if let RecorderError::BiliClientError { err: _ } = e {
connection_fail_count =
std::cmp::min(5, connection_fail_count + 1);
}
break;
}
}
}
*self_clone.is_recording.write().await = false;
// go check status again after random 2-5 secs
let mut rng = rand::thread_rng();
let secs = rng.gen_range(2..=5);
thread::sleep(std::time::Duration::from_secs(secs));
tokio::time::sleep(Duration::from_secs(
secs + 2_u64.pow(connection_fail_count),
))
.await;
continue;
}
// Every 10s check live status.
@@ -895,7 +904,7 @@ impl super::Recorder for BiliRecorder {
/// timestamp is the id of live stream
async fn m3u8_content(&self, live_id: &str, start: i64, end: i64) -> String {
if *self.live_id.read().await == live_id && *self.current_record.read().await {
if *self.live_id.read().await == live_id && self.should_record().await {
self.generate_live_m3u8(start, end).await
} else {
self.generate_archive_m3u8(live_id, start, end).await
@@ -991,11 +1000,11 @@ impl super::Recorder for BiliRecorder {
}
async fn force_start(&self) {
*self.current_record.write().await = true;
*self.manual_stop_id.write().await = None;
}
async fn force_stop(&self) {
*self.current_record.write().await = false;
*self.manual_stop_id.write().await = Some(self.live_id.read().await.clone());
}
async fn set_auto_start(&self, auto_start: bool) {

View File

@@ -12,7 +12,7 @@ use crate::{config::Config, database::account::AccountRow};
use async_trait::async_trait;
use chrono::Utc;
use client::DouyinClientError;
use dashmap::DashMap;
use rand::random;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, RwLock};
@@ -50,12 +50,11 @@ pub struct DouyinRecorder {
pub entry_store: Arc<RwLock<Option<EntryStore>>>,
pub live_id: Arc<RwLock<String>>,
pub live_status: Arc<RwLock<LiveStatus>>,
manual_stop_id: Arc<RwLock<Option<String>>>,
is_recording: Arc<RwLock<bool>>,
auto_start: Arc<RwLock<bool>>,
current_record: Arc<RwLock<bool>>,
running: Arc<RwLock<bool>>,
last_update: Arc<RwLock<i64>>,
m3u8_cache: DashMap<String, String>,
config: Arc<RwLock<Config>>,
live_end_channel: broadcast::Sender<RecorderEvent>,
}
@@ -88,12 +87,11 @@ impl DouyinRecorder {
room_info: Arc::new(RwLock::new(Some(room_info))),
stream_url: Arc::new(RwLock::new(None)),
live_status: Arc::new(RwLock::new(live_status)),
manual_stop_id: Arc::new(RwLock::new(None)),
running: Arc::new(RwLock::new(false)),
is_recording: Arc::new(RwLock::new(false)),
auto_start: Arc::new(RwLock::new(auto_start)),
current_record: Arc::new(RwLock::new(false)),
last_update: Arc::new(RwLock::new(Utc::now().timestamp())),
m3u8_cache: DashMap::new(),
config,
live_end_channel: channel,
})
@@ -104,26 +102,28 @@ impl DouyinRecorder {
return false;
}
*self.current_record.read().await
let live_id = self.live_id.read().await.clone();
self.manual_stop_id
.read()
.await
.as_ref()
.is_none_or(|v| v != &live_id)
}
async fn check_status(&self) -> bool {
match self.client.get_room_info(self.room_id).await {
Ok(info) => {
let live_status = info.data.room_status == 0; // room_status == 0 表示正在直播
let previous_liveid = self.live_id.read().await.clone();
*self.room_info.write().await = Some(info.clone());
if (*self.live_status.read().await == LiveStatus::Live) != live_status {
// live status changed, reset current record flag
*self.current_record.write().await = false;
log::info!(
"[{}]Live status changed to {}, current_record: {}, auto_start: {}",
"[{}]Live status changed to {}, auto_start: {}",
self.room_id,
live_status,
*self.current_record.read().await,
*self.auto_start.read().await
);
@@ -168,23 +168,18 @@ impl DouyinRecorder {
}
if !live_status {
*self.current_record.write().await = false;
self.reset().await;
return false;
}
if !*self.current_record.read().await && !*self.auto_start.read().await {
let should_record = self.should_record().await;
if !should_record && !*self.auto_start.read().await {
return true;
}
if *self.auto_start.read().await
&& previous_liveid != info.data.data[0].id_str.clone()
{
*self.current_record.write().await = true;
}
if *self.current_record.read().await {
if should_record {
// Get stream URL when live starts
if !info.data.data[0]
.stream_url
@@ -363,7 +358,7 @@ impl DouyinRecorder {
sequence,
length: segment.duration as f64,
size,
ts: Utc::now().timestamp(),
ts: Utc::now().timestamp_millis(),
is_header: false,
};
@@ -377,6 +372,8 @@ impl DouyinRecorder {
}
Err(e) => {
log::error!("Failed to download segment: {}", e);
*self.stream_url.write().await = None;
return Err(e.into());
}
}
}
@@ -409,6 +406,7 @@ impl DouyinRecorder {
}
async fn generate_m3u8(&self, live_id: &str, start: i64, end: i64) -> String {
log::debug!("Generate m3u8 for {live_id}:{start}:{end}");
let range = if start != 0 || end != 0 {
Some(Range {
x: start as f32,
@@ -425,7 +423,7 @@ impl DouyinRecorder {
.await
.as_ref()
.unwrap()
.manifest(false, false, range)
.manifest(range.is_some(), false, range)
} else {
let work_dir = self.get_work_dir(live_id).await;
EntryStore::new(&work_dir)
@@ -443,6 +441,7 @@ impl Recorder for DouyinRecorder {
let self_clone = self.clone();
tokio::spawn(async move {
while *self_clone.running.read().await {
let mut connection_fail_count = 0;
if self_clone.check_status().await {
// Live status is ok, start recording
while self_clone.should_record().await {
@@ -460,16 +459,25 @@ impl Recorder for DouyinRecorder {
);
}
*self_clone.is_recording.write().await = true;
connection_fail_count = 0;
}
Err(e) => {
log::error!("[{}]Update entries error: {}", self_clone.room_id, e);
if let RecorderError::DouyinClientError { err: _e } = e {
connection_fail_count =
std::cmp::min(5, connection_fail_count + 1);
}
break;
}
}
}
*self_clone.is_recording.write().await = false;
// Check status again after 2-5 seconds
tokio::time::sleep(Duration::from_secs(2)).await;
// Check status again after some seconds
let secs = random::<u64>() % 5;
tokio::time::sleep(Duration::from_secs(
secs + 2_u64.pow(connection_fail_count),
))
.await;
continue;
}
// Check live status every 10s
@@ -484,18 +492,7 @@ impl Recorder for DouyinRecorder {
}
async fn m3u8_content(&self, live_id: &str, start: i64, end: i64) -> String {
let cache_key = format!("{}:{}:{}", live_id, start, end);
let range_required = start != 0 || end != 0;
if !range_required {
return self.generate_m3u8(live_id, start, end).await;
}
if let Some(cached) = self.m3u8_cache.get(&cache_key) {
return cached.clone();
}
let m3u8_content = self.generate_m3u8(live_id, start, end).await;
self.m3u8_cache.insert(cache_key, m3u8_content.clone());
m3u8_content
self.generate_m3u8(live_id, start, end).await
}
async fn master_m3u8(&self, _live_id: &str, start: i64, end: i64) -> String {
@@ -583,11 +580,11 @@ impl Recorder for DouyinRecorder {
}
async fn force_start(&self) {
*self.current_record.write().await = true;
*self.manual_stop_id.write().await = None;
}
async fn force_stop(&self) {
*self.current_record.write().await = false;
*self.manual_stop_id.write().await = Some(self.live_id.read().await.clone());
}
async fn set_auto_start(&self, auto_start: bool) {

View File

@@ -47,28 +47,33 @@ impl TsEntry {
})
}
/// Get timestamp in seconds
pub fn ts_seconds(&self) -> i64 {
// For some legacy problem, douyin entry's ts is s, bilibili entry's ts is ms.
// This should be fixed after 2.5.6, but we need to support entry.log generated by previous version.
if self.ts > 1619884800000 {
self.ts / 1000
} else {
self.ts
}
}
pub fn date_time(&self) -> String {
let date_str = Utc.timestamp_opt(self.ts / 1000, 0).unwrap().to_rfc3339();
let date_str = Utc
.timestamp_opt(self.ts_seconds(), 0)
.unwrap()
.to_rfc3339();
format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str)
}
/// Convert entry into a segment in HLS manifest.
/// If `continuous` is false, DISCONTINUITY and DATE-TIME will be added into tags, so that player can get precise video time for danmaku display.
/// If `force_time` is true, DATE-TIME will be added into tags which ignores `continuous`.
pub fn to_segment(&self, continuous: bool, force_time: bool) -> String {
pub fn to_segment(&self) -> String {
if self.is_header {
return "".into();
}
let mut content = if continuous {
String::new()
} else {
"#EXT-X-DISCONTINUITY\n".into()
};
let mut content = String::new();
if !continuous || force_time {
content += &self.date_time();
}
content += &format!("#EXTINF:{:.2},\n", self.length);
content += &format!("{}\n", self.url);
@@ -235,23 +240,41 @@ impl EntryStore {
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", header.url);
}
// Collect entries in range
let first_entry = self.entries.first().unwrap();
let first_entry_ts = first_entry.ts / 1000;
let mut previous_seq = first_entry.sequence;
let first_entry_ts = first_entry.ts_seconds();
let mut entries_in_range = vec![];
for e in &self.entries {
// ignore header, cause it's already in EXT-X-MAP
if e.is_header {
continue;
}
let discontinuous = e.sequence < previous_seq || e.sequence - previous_seq > 1;
previous_seq = e.sequence;
let entry_offset = (e.ts / 1000 - first_entry_ts) as f32;
let entry_offset = (e.ts_seconds() - first_entry_ts) as f32;
if range.is_none_or(|r| r.is_in(entry_offset)) {
m3u8_content += &e.to_segment(!discontinuous, force_time);
entries_in_range.push(e);
}
}
if entries_in_range.is_empty() {
m3u8_content += end_content;
return m3u8_content;
}
let mut previous_seq = entries_in_range.first().unwrap().sequence;
for (i, e) in entries_in_range.iter().enumerate() {
let discontinuous = e.sequence < previous_seq || e.sequence - previous_seq > 1;
if discontinuous {
m3u8_content += "#EXT-X-DISCONTINUITY\n".into();
}
// Add date time under these situations.
if i == 0 || i == entries_in_range.len() - 1 || force_time || discontinuous {
m3u8_content += &e.date_time();
}
m3u8_content += &e.to_segment();
previous_seq = e.sequence;
}
m3u8_content += end_content;
m3u8_content
}

View File

@@ -5,29 +5,33 @@
import Setting from "./page/Setting.svelte";
import Account from "./page/Account.svelte";
import About from "./page/About.svelte";
let active = "#总览";
let active = "总览";
</script>
<main>
<div class="wrap">
<div class="sidebar">
<BSidebar bind:activeUrl={active} />
<BSidebar
bind:activeUrl={active}
on:activeChange={(e) => {
active = e.detail;
}}
/>
</div>
<div class="content bg-white dark:bg-[#2c2c2e]">
<!-- switch component by active -->
<div class="page" class:visible={active == "#总览"}>
<div class="page" class:visible={active == "总览"}>
<Summary />
</div>
<div class="page" class:visible={active == "#直播间"}>
<div class="page" class:visible={active == "直播间"}>
<Room />
</div>
<div class="page" class:visible={active == "#账号"}>
<div class="page" class:visible={active == "账号"}>
<Account />
</div>
<div class="page" class:visible={active == "#设置"}>
<div class="page" class:visible={active == "设置"}>
<Setting />
</div>
<div class="page" class:visible={active == "#关于"}>
<div class="page" class:visible={active == "关于"}>
<About />
</div>
</div>

View File

@@ -1,12 +1,18 @@
<script>
import { Info, LayoutDashboard, Settings, Users, Video } from "lucide-svelte";
import { hasNewVersion } from "./stores/version";
import SidebarItem from "./SidebarItem.svelte";
import { createEventDispatcher } from "svelte";
// acitveUrl is shared between project
export let activeUrl = "#总览";
const dispatch = createEventDispatcher();
export let activeUrl = "总览";
/**
* @param {{ detail: String; }} route
*/
function navigate(route) {
activeUrl = route;
dispatch("activeChange", route.detail);
}
</script>
@@ -14,81 +20,36 @@
class="w-48 bg-[#f0f0f3]/50 dark:bg-[#2c2c2e]/50 backdrop-blur-xl border-r border-gray-200 dark:border-gray-700"
>
<nav class="p-3 space-y-1">
<button
on:click={() => navigate("#总览")}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl ===
'#总览'
? 'bg-blue-500/10 text-[#0A84FF]'
: 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
<SidebarItem label="总览" {activeUrl} on:activeChange={navigate}>
<div slot="icon">
<LayoutDashboard class="w-5 h-5" />
</div>
</SidebarItem>
<SidebarItem label="直播间" {activeUrl} on:activeChange={navigate}>
<div slot="icon">
<Video class="w-5 h-5" />
</div>
</SidebarItem>
<SidebarItem label="账号" {activeUrl} on:activeChange={navigate}>
<div slot="icon">
<Users class="w-5 h-5" />
</div>
</SidebarItem>
<SidebarItem label="设置" {activeUrl} on:activeChange={navigate}>
<div slot="icon">
<Settings class="w-5 h-5" />
</div>
</SidebarItem>
<SidebarItem
label="关于"
{activeUrl}
on:activeChange={navigate}
dot={$hasNewVersion}
>
<LayoutDashboard
class="w-5 h-5 {activeUrl === '#总览'
? 'text-[#0A84FF]'
: 'text-gray-700 dark:text-[#0A84FF]'}"
/>
<span>总览</span>
</button>
<button
on:click={() => navigate("#直播间")}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl ===
'#直播间'
? 'bg-blue-500/10 text-[#0A84FF]'
: 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
>
<Video
class="w-5 h-5 {activeUrl === '#直播间'
? 'text-[#0A84FF]'
: 'text-gray-700 dark:text-[#0A84FF]'}"
/>
<span>直播间</span>
</button>
<button
on:click={() => navigate("#账号")}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl ===
'#账号'
? 'bg-blue-500/10 text-[#0A84FF]'
: 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
>
<Users
class="w-5 h-5 {activeUrl === '#账号'
? 'text-[#0A84FF]'
: 'text-gray-700 dark:text-[#0A84FF]'}"
/>
<span>账号</span>
</button>
<button
on:click={() => navigate("#设置")}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl ===
'#设置'
? 'bg-blue-500/10 text-[#0A84FF]'
: 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
>
<Settings
class="w-5 h-5 {activeUrl === '#设置'
? 'text-[#0A84FF]'
: 'text-gray-700 dark:text-[#0A84FF]'}"
/>
<span>设置</span>
</button>
<button
on:click={() => navigate("#关于")}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl ===
'#关于'
? 'bg-blue-500/10 text-[#0A84FF]'
: 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c] relative"
>
<Info
class="w-5 h-5 {activeUrl === '#关于'
? 'text-[#0A84FF]'
: 'text-gray-700 dark:text-[#0A84FF]'}"
/>
<span>关于</span>
{#if $hasNewVersion}
<div
class="absolute right-3 top-1/2 -translate-y-1/2 w-2 h-2 bg-red-500 rounded-full"
></div>
{/if}
</button>
<div slot="icon">
<Info class="w-5 h-5" />
</div>
</SidebarItem>
</nav>
</div>

View File

@@ -67,6 +67,8 @@
timedOut: false,
};
let manifestPrintCnt = 0;
const pendingRequest = new Promise((resolve, reject) => {
invoke("fetch_hls", { uri: uri })
.then((data: number[]) => {
@@ -81,19 +83,28 @@
const is_m3u8 = uri.split("?")[0].endsWith(".m3u8");
if (is_m3u8 && global_offset == 0) {
if (is_m3u8) {
let m3u8Content = new TextDecoder().decode(uint8Array);
const offsetRegex = /DANMU=(\d+)/;
const match = m3u8Content.match(offsetRegex);
if (global_offset == 0) {
const offsetRegex = /DANMU=(\d+)/;
const match = m3u8Content.match(offsetRegex);
if (match && match[1]) {
global_offset = parseInt(match[1], 10);
console.log("DANMU OFFSET found", global_offset);
} else {
console.warn("No DANMU OFFSET found");
if (match && match[1]) {
global_offset = parseInt(match[1], 10);
console.log("DANMU OFFSET found", global_offset);
} else {
console.warn("No DANMU OFFSET found");
}
}
// Print manifest for debugging every 30 times.
if (manifestPrintCnt == 0) {
console.log(m3u8Content);
} else {
manifestPrintCnt = (manifestPrintCnt + 1) % 30;
}
}
// Set content-type based on URI extension
let content_type = is_m3u8
? "application/vnd.apple.mpegurl"

View File

@@ -0,0 +1,30 @@
<script>
import { Info, LayoutDashboard, Settings, Users, Video } from "lucide-svelte";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
// acitveUrl is shared between project
export let activeUrl = "总览";
export let label = "";
export let dot = false;
</script>
<button
on:click={() => dispatch("activeChange", label)}
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl ===
label
? 'bg-blue-500/10 text-[#0A84FF]'
: 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
>
<slot
name="icon"
class={activeUrl === label
? "text-[#0A84FF]"
: "text-gray-700 dark:text-[#0A84FF]"}
></slot>
<span>{label}</span>
{#if dot}
<div class="absolute right-6 w-2 h-2 bg-red-500 rounded-full"></div>
{/if}
</button>

View File

@@ -20,6 +20,7 @@
version: release.tag_name,
date: new Date(release.published_at).toLocaleDateString(),
description: release.body,
url: release.html_url,
}));
});
@@ -28,7 +29,7 @@
return notes
.split("\n")
.filter(
(line) => line.trim().startsWith("*") || line.trim().startsWith("-")
(line) => line.trim().startsWith("*") || line.trim().startsWith("-"),
)
.map((line) => {
line = line.trim().replace(/^[*-]\s*/, "");
@@ -134,10 +135,14 @@
class="bg-white dark:bg-[#3c3c3e] rounded-xl border border-gray-200 dark:border-gray-700"
>
{#each releases as release}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="p-4 {release !== releases[releases.length - 1]
class="p-4 cursor-pointer {release !== releases[releases.length - 1]
? 'border-b border-gray-200 dark:border-gray-700'
: ''}"
on:click={() => {
open(release.url);
}}
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">