Compare commits

...

15 Commits

Author SHA1 Message Date
Xinrea
0d053a3462 release: bump version to 1.2.0 2024-11-07 02:08:07 +08:00
Xinrea
280e540f4f fix(recorder): using "-" instead of "|" to separate offset and sequence
When using "|" in file name, ffmpeg concat is not able to work. So now
we change it to "-".
Here is an example for new format of segment filename:
18b7af7-89947993.m4s
2024-11-07 01:39:40 +08:00
Xinrea
824cfd23ed fix(recorder): using global offset to find clip segments range
The clip range drift problem is finally solved by calculate
date-time for every segment. With date-time, we can ignore
gaps in m3u8 segments. close #5
2024-11-07 01:16:20 +08:00
Xinrea
695728df2e feat(recorder): add DATE-TIME tag for segments 2024-11-06 20:04:49 +08:00
Xinrea
24deca75d2 fix(recorder_manager): handle cors preflight request for hls server 2024-11-06 20:04:13 +08:00
Xinrea
8a1184f161 fix(recorder): clip using accurate segment length 2024-11-06 12:04:02 +08:00
Xinrea
d61096d1b1 refactor(recorder): calculate segment length in entry-creation 2024-11-06 02:14:42 +08:00
Xinrea
3b9d1be002 fix: use accurate segment length to prevent video time drift 2024-11-04 20:00:38 +08:00
Xinrea
13262f8f10 feat: add history danmu replay (close #16) 2024-11-03 21:24:54 +08:00
Xinrea
9f05fc4954 release: bump version to 1.1.0 2024-10-30 21:32:49 +08:00
Xinrea
3fce06ef63 chore: code format 2024-10-30 21:30:40 +08:00
Xinrea
3d13f69e5c feat: log output to file 2024-10-30 21:28:43 +08:00
Xinrea
deb19c6223 feat: change clip encoding to copy for speed 2024-10-30 21:01:58 +08:00
Xinrea
7466127832 feat: add account using cookie str (close #19) 2024-10-30 20:55:35 +08:00
Xinrea
af982c5fe0 fix: project url on about page 2024-10-30 18:38:25 +08:00
10 changed files with 620 additions and 201 deletions

View File

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

View File

@@ -329,7 +329,6 @@ impl Database {
}
}
// CREATE TABLE videos (id INTEGER PRIMARY KEY, room_id INTEGER, cover TEXT, file TEXT, length INTEGER, size INTEGER, status INTEGER, bvid TEXT, title TEXT, desc TEXT, tags TEXT, area INTEGER, created_at TEXT);
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct VideoRow {
@@ -351,21 +350,34 @@ pub struct VideoRow {
impl Database {
pub async fn get_videos(&self, room_id: u64) -> Result<Vec<VideoRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
.bind(room_id as i64)
.fetch_all(&lock)
.await?)
Ok(
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
.bind(room_id as i64)
.fetch_all(&lock)
.await?,
)
}
pub async fn get_video(&self, id: i64) -> Result<VideoRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
.bind(id)
.fetch_one(&lock)
.await?)
Ok(
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
.bind(id)
.fetch_one(&lock)
.await?,
)
}
pub async fn update_video(&self, video_id: i64, status: i64, bvid: &str, title: &str, desc: &str, tags: &str, area: u64) -> Result<(), DatabaseError> {
pub async fn update_video(
&self,
video_id: i64,
status: i64,
bvid: &str,
title: &str,
desc: &str,
tags: &str,
area: u64,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("UPDATE videos SET status = $1, bvid = $2, title = $3, desc = $4, tags = $5, area = $6 WHERE id = $7")
.bind(status)

View File

@@ -11,13 +11,15 @@ use db::{AccountRow, Database, MessageRow, RecordRow, VideoRow};
use recorder::bilibili::errors::BiliClientError;
use recorder::bilibili::profile::Profile;
use recorder::bilibili::{BiliClient, QrInfo, QrStatus};
use recorder::danmu::DanmuEntry;
use recorder_manager::{RecorderInfo, RecorderList, RecorderManager};
use tauri_plugin_notification::NotificationExt;
use std::fs::File;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use tauri::utils::config::WindowEffectsConfig;
use tauri::{Manager, Theme, WindowEvent};
use tauri_plugin_notification::NotificationExt;
use tauri_plugin_sql::{Migration, MigrationKind};
use tokio::sync::RwLock;
@@ -366,7 +368,13 @@ async fn set_cache_path(state: tauri::State<'_, State>, cache_path: String) -> R
std::thread::sleep(std::time::Duration::from_secs(2));
// Copy old cache to new cache
log::info!("Start copy old cache to new cache");
state.db.new_message("缓存目录切换", "缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用").await?;
state
.db
.new_message(
"缓存目录切换",
"缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用",
)
.await?;
if let Err(e) = copy_dir_all(&old_cache_path, &cache_path) {
log::error!("Copy old cache to new cache error: {}", e);
}
@@ -382,7 +390,13 @@ async fn set_cache_path(state: tauri::State<'_, State>, cache_path: String) -> R
}
#[tauri::command]
async fn update_notify(state: tauri::State<'_, State>, live_start_notify: bool, live_end_notify: bool, clip_notify: bool, post_notify: bool) -> Result<(), ()> {
async fn update_notify(
state: tauri::State<'_, State>,
live_start_notify: bool,
live_end_notify: bool,
clip_notify: bool,
post_notify: bool,
) -> Result<(), ()> {
state.config.write().await.live_start_notify = live_start_notify;
state.config.write().await.live_end_notify = live_end_notify;
state.config.write().await.clip_notify = clip_notify;
@@ -472,7 +486,14 @@ async fn clip_range(
)
.await?;
if state.config.read().await.clip_notify {
state.app_handle.notification().builder().title("BiliShadowReplay - 切片完成").body(format!("生成了房间 {} 的切片: {}", room_id, filename)).show().unwrap();
state
.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 切片完成")
.body(format!("生成了房间 {} 的切片: {}", room_id, filename))
.show()
.unwrap();
}
Ok(video)
}
@@ -519,7 +540,14 @@ async fn upload_procedure(
)
.await?;
if state.config.read().await.post_notify {
state.app_handle.notification().builder().title("BiliShadowReplay - 投稿成功").body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid)).show().unwrap();
state
.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 投稿成功")
.body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid))
.show()
.unwrap();
}
Ok(ret.bvid)
} else {
@@ -592,6 +620,15 @@ async fn send_danmaku(
Ok(())
}
#[tauri::command]
async fn get_danmu_record(
state: tauri::State<'_, State>,
room_id: u64,
ts: u64,
) -> Result<Vec<DanmuEntry>, String> {
Ok(state.recorder_manager.get_danmu(room_id, ts).await?)
}
#[derive(serde::Serialize)]
struct AccountInfo {
pub primary_uid: u64,
@@ -707,12 +744,19 @@ async fn delete_video(state: tauri::State<'_, State>, id: i64) -> Result<(), Str
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Setup log
simplelog::CombinedLogger::init(vec![simplelog::TermLogger::new(
simplelog::LevelFilter::Info,
simplelog::Config::default(),
simplelog::TerminalMode::Mixed,
simplelog::ColorChoice::Auto,
)])
simplelog::CombinedLogger::init(vec![
simplelog::TermLogger::new(
simplelog::LevelFilter::Info,
simplelog::Config::default(),
simplelog::TerminalMode::Mixed,
simplelog::ColorChoice::Auto,
),
simplelog::WriteLogger::new(
simplelog::LevelFilter::Info,
simplelog::Config::default(),
File::create("bsr.log").unwrap(),
),
])
.unwrap();
// Setup ffmpeg
@@ -814,12 +858,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Ok(account) = account {
for room in initial_rooms {
if let Err(e) = recorder_manager_clone
.add_recorder(
&webid,
&db_clone,
&account,
room.room_id,
)
.add_recorder(&webid, &db_clone, &account, room.room_id)
.await
{
log::error!("error when adding initial rooms: {}", e);
@@ -879,6 +918,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
get_disk_info,
send_danmaku,
update_notify,
get_danmu_record,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -1,9 +1,12 @@
pub mod bilibili;
pub mod danmu;
use async_std::{fs, stream::StreamExt};
use bilibili::{errors::BiliClientError, RoomInfo};
use bilibili::{BiliClient, UserInfo};
use chrono::prelude::*;
use custom_error::custom_error;
use danmu::{DanmuEntry, DanmuStorage};
use dashmap::DashMap;
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
use ffmpeg_sidecar::{
command::FfmpegCommand,
@@ -12,10 +15,10 @@ use ffmpeg_sidecar::{
use futures::future::join_all;
use m3u8_rs::Playlist;
use regex::Regex;
use tauri_plugin_notification::NotificationExt;
use std::sync::Arc;
use std::thread;
use tauri::{AppHandle, Emitter};
use tauri_plugin_notification::NotificationExt;
use tokio::sync::mpsc::{self, UnboundedReceiver};
use tokio::sync::{Mutex, RwLock};
@@ -25,8 +28,9 @@ use crate::Config;
#[derive(Clone)]
pub struct TsEntry {
pub url: String,
pub offset: u64,
pub sequence: u64,
pub _length: f64,
pub length: f64,
pub size: u64,
}
@@ -55,6 +59,8 @@ pub struct BiliRecorder {
header: Arc<RwLock<Option<TsEntry>>>,
stream_type: Arc<RwLock<StreamType>>,
cache_size: Arc<RwLock<u64>>,
danmu_storage: Arc<RwLock<Option<DanmuStorage>>>,
m3u8_cache: DashMap<u64, String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -133,6 +139,8 @@ impl BiliRecorder {
header: Arc::new(RwLock::new(None)),
stream_type: Arc::new(RwLock::new(stream_type)),
cache_size: Arc::new(RwLock::new(0)),
danmu_storage: Arc::new(RwLock::new(None)),
m3u8_cache: DashMap::new(),
};
log::info!("Recorder for room {} created.", room_id);
Ok(recorder)
@@ -144,6 +152,7 @@ impl BiliRecorder {
self.ts_entries.lock().await.clear();
*self.header.write().await = None;
*self.timestamp.write().await = 0;
*self.danmu_storage.write().await = None;
}
async fn check_status(&self) -> bool {
@@ -165,14 +174,25 @@ impl BiliRecorder {
.notification()
.builder()
.title("BiliShadowReplay - 直播开始")
.body(format!("{} 开启了直播:{}",self.user_info.read().await.user_name, room_info.room_title)).show().unwrap();
.body(format!(
"{} 开启了直播:{}",
self.user_info.read().await.user_name,
room_info.room_title
))
.show()
.unwrap();
}
} else if self.config.read().await.live_end_notify {
self.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 直播结束")
.body(format!("{} 的直播结束了",self.user_info.read().await.user_name)).show().unwrap();
.body(format!(
"{} 的直播结束了",
self.user_info.read().await.user_name
))
.show()
.unwrap();
}
}
// if stream is confirmed to be closed, live stream cache is cleaned.
@@ -271,8 +291,20 @@ impl BiliRecorder {
while let Some(msg) = rx.recv().await {
if let WsStreamMessageType::DanmuMsg(msg) = msg {
self.app_handle
.emit(&format!("danmu:{}", room), msg.msg.clone())
.emit(
&format!("danmu:{}", room),
DanmuEntry {
ts: msg.timestamp,
content: msg.msg.clone(),
},
)
.unwrap();
if *self.live_status.read().await {
// save danmu
if let Some(storage) = self.danmu_storage.write().await.as_ref() {
storage.add_line(msg.timestamp, &msg.msg).await;
}
}
}
}
Ok(())
@@ -360,7 +392,12 @@ impl BiliRecorder {
async fn update_entries(&self) -> Result<(), RecorderError> {
let parsed = self.get_playlist().await;
let mut timestamp = *self.timestamp.read().await;
let mut work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
let mut work_dir = format!(
"{}/{}/{}/",
self.config.read().await.cache,
self.room_id,
timestamp
);
// Check header if None
if self.header.read().await.is_none() && *self.stream_type.read().await == StreamType::FMP4
{
@@ -382,7 +419,12 @@ impl BiliRecorder {
)
.await?;
// now work dir is confirmed
work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
work_dir = format!(
"{}/{}/{}/",
self.config.read().await.cache,
self.room_id,
timestamp
);
// if folder is exisited, need to load previous data into cache
if let Ok(meta) = fs::metadata(&work_dir).await {
if meta.is_dir() {
@@ -396,11 +438,18 @@ impl BiliRecorder {
// make sure work_dir is created
fs::create_dir_all(&work_dir).await.unwrap();
}
// danmau file
let danmu_file_path = format!("{}{}", work_dir, "danmu.txt");
self.danmu_storage
.write()
.await
.replace(DanmuStorage::new(&danmu_file_path).await);
let full_header_url = self.ts_url(&header_url).await?;
let mut header = TsEntry {
url: full_header_url.clone(),
offset: 0,
sequence: 0,
_length: 0.0,
length: 0.0,
size: 0,
};
let file_name = header_url.split('/').last().unwrap();
@@ -433,27 +482,51 @@ impl BiliRecorder {
sequence += 1;
continue;
}
let mut ts_entry = TsEntry {
url: ts.uri,
sequence,
_length: ts.duration as f64,
size: 0,
};
let client = self.client.clone();
let ts_url = self.ts_url(&ts_entry.url).await?;
ts_entry.url = ts_url.clone();
let mut offset_hex: String = "".into();
let mut seg_offset: u64 = 0;
for tag in ts.unknown_tags {
if tag.tag == "BILI-AUX" {
if let Some(rest) = tag.rest {
let parts: Vec<&str> = rest.split('|').collect();
if parts.len() == 0 {
continue;
}
offset_hex = parts.get(0).unwrap().to_string();
seg_offset = u64::from_str_radix(&offset_hex, 16).unwrap();
}
break;
}
}
let ts_url = self.ts_url(&ts.uri).await?;
if ts_url.is_empty() {
continue;
}
// encode segment offset into filename
let mut entries = self.ts_entries.lock().await;
let file_name =
format!("{}-{}", &offset_hex, ts_url.split('/').last().unwrap());
let mut ts_length = 1.0;
// calculate entry length using offset
// the default #EXTINF is 1.0, which is not accurate
if !entries.is_empty() {
ts_length = (seg_offset - entries.last().unwrap().offset) as f64 / 1000.0;
}
let ts_entry = TsEntry {
url: file_name.clone(),
offset: seg_offset,
sequence,
length: ts_length,
size: 0,
};
let client = self.client.clone();
let work_dir = work_dir.clone();
let cache_size_clone = self.cache_size.clone();
handles.push(tokio::task::spawn(async move {
let ts_url_clone = ts_url.clone();
let file_name = ts_url_clone.split('/').last().unwrap();
let file_name_clone = file_name.clone();
match client
.read()
.await
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name))
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name_clone))
.await
{
Ok(size) => {
@@ -464,7 +537,6 @@ impl BiliRecorder {
}
}
}));
let mut entries = self.ts_entries.lock().await;
entries.push(ts_entry);
*self.last_sequence.write().await = sequence;
let mut total_length = self.ts_length.write().await;
@@ -473,7 +545,7 @@ impl BiliRecorder {
}
join_all(handles).await.into_iter().for_each(|e| {
if let Err(e) = e {
log::error!("download ts failed: {:?}", e);
log::error!("Download ts failed: {:?}", e);
}
});
// currently we take every segement's length as 1.0s.
@@ -533,7 +605,7 @@ impl BiliRecorder {
y: f64,
output_path: &str,
) -> Result<String, RecorderError> {
log::info!("create archive clip for range [{}, {}]", x, y);
log::info!("Create archive clip for range [{}, {}]", x, y);
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, ts);
let entries = self.get_fs_entries(&work_dir).await;
if entries.is_empty() {
@@ -544,19 +616,20 @@ impl BiliRecorder {
file_list += &format!("{}/h{}.m4s", work_dir, ts);
file_list += "|";
// add body entries
let mut offset = 0.0;
// seconds to ms
let begin = (x * 1000.0) as u64;
let end = (y * 1000.0) as u64;
let offset = entries.first().unwrap().offset;
if !entries.is_empty() {
for e in entries {
if offset < x {
offset += 1.0;
if e.offset - offset < begin {
continue;
}
file_list += &format!("{}/{}", work_dir, e.url);
file_list += "|";
if offset > y {
if e.offset - offset > end {
break;
}
offset += 1.0;
}
}
@@ -570,7 +643,7 @@ impl BiliRecorder {
y - x
);
log::info!("{}", file_name);
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
let args = format!("-i concat:{} -c copy", file_list);
FfmpegCommand::new()
.args(args.split(' '))
.output(file_name.clone())
@@ -592,29 +665,25 @@ impl BiliRecorder {
y: f64,
output_path: &str,
) -> Result<String, RecorderError> {
log::info!("create live clip for range [{}, {}]", x, y);
log::info!("Create live clip for range [{}, {}]", x, y);
let mut to_combine = Vec::new();
let header_copy = self.header.read().await.clone();
let entry_copy = self.ts_entries.lock().await.clone();
if entry_copy.is_empty() {
return Err(RecorderError::EmptyCache);
}
let mut start = x;
let mut end = y;
if start > end {
std::mem::swap(&mut start, &mut end);
}
let mut offset = 0.0;
let begin = (x * 1000.0) as u64;
let end = (y * 1000.0) as u64;
let offset = entry_copy.first().unwrap().offset;
// TODO using binary search
for e in entry_copy.iter() {
if offset < start {
offset += 1.0;
if e.offset - offset < begin {
continue;
}
to_combine.push(e);
if offset >= end {
if e.offset - offset > end {
break;
}
offset += 1.0;
}
if *self.stream_type.read().await == StreamType::FMP4 {
// add header to vec
@@ -627,7 +696,10 @@ impl BiliRecorder {
let file_name = e.url.split('/').last().unwrap();
let file_path = format!(
"{}/{}/{}/{}",
self.config.read().await.cache, self.room_id, timestamp, file_name
self.config.read().await.cache,
self.room_id,
timestamp,
file_name
);
file_list += &file_path;
file_list += "|";
@@ -641,10 +713,10 @@ impl BiliRecorder {
self.room_id,
title,
Utc::now().format("%m%d%H%M%S"),
end - start
y - x
);
log::info!("{}", file_name);
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
let args = format!("-i concat:{} -c copy", file_list);
FfmpegCommand::new()
.args(args.split(' '))
.output(file_name.clone())
@@ -670,6 +742,9 @@ impl BiliRecorder {
}
async fn generate_archive_m3u8(&self, timestamp: u64) -> String {
if self.m3u8_cache.contains_key(&timestamp) {
return self.m3u8_cache.get(&timestamp).unwrap().clone();
}
let mut m3u8_content = "#EXTM3U\n".to_string();
m3u8_content += "#EXT-X-VERSION:6\n";
m3u8_content += "#EXT-X-TARGETDURATION:1\n";
@@ -679,22 +754,35 @@ impl BiliRecorder {
let header_url = format!("/{}/{}/h{}.m4s", self.room_id, timestamp, timestamp);
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", header_url);
// add entries from read_dir
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, timestamp);
let work_dir = format!(
"{}/{}/{}",
self.config.read().await.cache,
self.room_id,
timestamp
);
let entries = self.get_fs_entries(&work_dir).await;
if entries.is_empty() {
return m3u8_content;
}
let mut last_sequence = entries.first().unwrap().sequence;
m3u8_content += &format!("#EXT-X-OFFSET:{}\n", entries.first().unwrap().offset);
for e in entries {
let current_seq = e.sequence;
if current_seq - last_sequence > 1 {
m3u8_content += "#EXT-X-DISCONTINUITY\n"
}
last_sequence = current_seq;
m3u8_content += "#EXTINF:1,\n";
// add #EXT-X-PROGRAM-DATE-TIME with ISO 8601 date
let ts = timestamp + e.offset / 1000;
let date_str = Utc.timestamp_opt(ts as i64, 0).unwrap().to_rfc3339();
m3u8_content += &format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str);
m3u8_content += &format!("#EXTINF:{:.2},\n", e.length);
m3u8_content += &format!("/{}/{}/{}\n", self.room_id, timestamp, e.url);
last_sequence = current_seq;
}
m3u8_content += "#EXT-X-ENDLIST";
// cache this
self.m3u8_cache.insert(timestamp, m3u8_content.clone());
m3u8_content
}
@@ -719,18 +807,54 @@ impl BiliRecorder {
if !etype.is_file() {
continue;
}
if let Some(file_ext) = e.path().extension() {
let file_ext = file_ext.to_str().unwrap().to_string();
// need to exclude other files, such as danmu file
if file_ext != "m4s" {
continue;
}
} else {
continue;
}
let file_name = e.file_name().to_str().unwrap().to_string();
if file_name.starts_with("h") {
continue;
}
let meta_info: &str = file_name.split('.').next().unwrap();
let infos: Vec<&str> = meta_info.split('-').collect();
let offset: u64;
let sequence: u64;
// BREAKCHANGE do not support legacy files that not named with offset
if infos.len() == 1 {
continue;
} else {
if let Ok(parsed_offset) = u64::from_str_radix(infos.get(0).unwrap(), 16) {
offset = parsed_offset;
} else {
continue;
}
sequence = infos.get(1).unwrap().parse().unwrap();
}
ret.push(TsEntry {
url: file_name.clone(),
sequence: file_name.split('.').next().unwrap().parse().unwrap(),
_length: 1.0,
offset,
sequence,
length: 1.0,
size: e.metadata().await.unwrap().len(),
});
}
ret.sort_by(|a, b| a.sequence.cmp(&b.sequence));
if ret.is_empty() {
return ret;
}
let mut last_offset = ret.first().unwrap().offset;
for (i, entry) in ret.iter_mut().enumerate() {
if i == 0 {
continue;
}
entry.length = (entry.offset - last_offset) as f64 / 1000.0;
last_offset = entry.offset;
}
ret
}
@@ -755,16 +879,23 @@ impl BiliRecorder {
}
let entries = self.ts_entries.lock().await.clone();
if entries.is_empty() {
m3u8_content += "#EXT-X-OFFSET:0\n";
return m3u8_content;
}
let timestamp = *self.timestamp.read().await;
let mut last_sequence = entries.first().unwrap().sequence;
m3u8_content += &format!("#EXT-X-OFFSET:{}\n", entries.first().unwrap().offset);
for entry in entries.iter() {
if entry.sequence - last_sequence > 1 {
// discontinuity happens
m3u8_content += "#EXT-X-DISCONTINUITY\n"
}
// add #EXT-X-PROGRAM-DATE-TIME with ISO 8601 date
let ts = timestamp + entry.offset / 1000;
let date_str = Utc.timestamp_opt(ts as i64, 0).unwrap().to_rfc3339();
m3u8_content += &format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str);
m3u8_content += &format!("#EXTINF:{:.2},\n", entry.length,);
last_sequence = entry.sequence;
m3u8_content += "#EXTINF:1,\n";
let file_name = entry.url.split('/').last().unwrap();
let local_url = format!("/{}/{}/{}", self.room_id, timestamp, file_name);
m3u8_content += &format!("{}\n", local_url);
@@ -775,4 +906,30 @@ impl BiliRecorder {
}
m3u8_content
}
pub async fn get_danmu_record(&self, ts: u64) -> Vec<DanmuEntry> {
if ts == *self.timestamp.read().await {
// just return current cache content
match self.danmu_storage.read().await.as_ref() {
Some(storage) => {
return storage.get_entries().await;
}
None => {
return Vec::new();
}
}
} else {
// load disk cache
let cache_file_path = format!(
"{}/{}/{}/{}",
self.config.read().await.cache,
self.room_id,
ts,
"danmu.txt"
);
log::info!("loading danmu cache from {}", cache_file_path);
let storage = DanmuStorage::new(&cache_file_path).await;
return storage.get_entries().await;
}
}
}

View File

@@ -0,0 +1,66 @@
use serde::Serialize;
use tokio::io::AsyncWriteExt;
use tokio::{
fs::{File, OpenOptions},
io::{AsyncBufReadExt, BufReader},
sync::RwLock,
};
#[derive(Clone, Serialize)]
pub struct DanmuEntry {
pub ts: u64,
pub content: String,
}
pub struct DanmuStorage {
cache: RwLock<Vec<DanmuEntry>>,
file: RwLock<File>,
}
impl DanmuStorage {
pub async fn new(file_path: &str) -> DanmuStorage {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(file_path)
.await
.expect("create danmu.txt failed");
let reader = BufReader::new(file);
let mut lines = reader.lines();
let mut preload_cache: Vec<DanmuEntry> = Vec::new();
while let Ok(Some(line)) = lines.next_line().await {
let parts: Vec<&str> = line.split(':').collect();
let ts: u64 = parts[0].parse().unwrap();
let content = parts[1].to_string();
preload_cache.push(DanmuEntry { ts, content })
}
let file = OpenOptions::new()
.append(true)
.create(true)
.open(file_path)
.await
.expect("create danmu.txt failed");
return DanmuStorage {
cache: RwLock::new(preload_cache),
file: RwLock::new(file),
};
}
pub async fn add_line(&self, ts: u64, content: &str) {
self.cache.write().await.push(DanmuEntry {
ts,
content: content.to_string(),
});
let _ = self
.file
.write()
.await
.write(format!("{}:{}\n", ts, content).as_bytes())
.await;
}
pub async fn get_entries(&self) -> Vec<DanmuEntry> {
self.cache.read().await.clone()
}
}

View File

@@ -1,10 +1,12 @@
use crate::db::{AccountRow, Database, RecordRow};
use crate::recorder::bilibili::UserInfo;
use crate::recorder::danmu::DanmuEntry;
use crate::recorder::RecorderError;
use crate::recorder::{bilibili::RoomInfo, BiliRecorder};
use crate::Config;
use custom_error::custom_error;
use dashmap::DashMap;
use hyper::Method;
use hyper::{
service::{make_service_fn, service_fn},
Body, Request, Response, Server,
@@ -70,7 +72,6 @@ impl From<RecorderManagerError> for String {
}
impl RecorderManager {
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
RecorderManager {
app_handle,
@@ -123,11 +124,7 @@ impl RecorderManager {
return Err(RecorderManagerError::NotFound { room_id });
}
// remove related cache folder
let cache_folder = format!(
"{}/{}",
self.config.read().await.cache,
room_id
);
let cache_folder = format!("{}/{}", self.config.read().await.cache, room_id);
tokio::fs::remove_dir_all(cache_folder).await?;
Ok(())
}
@@ -231,6 +228,18 @@ impl RecorderManager {
}
}
pub async fn get_danmu(
&self,
room_id: u64,
live_id: u64,
) -> Result<Vec<DanmuEntry>, RecorderManagerError> {
if let Some(recorder) = self.recorders.get(&room_id) {
Ok(recorder.get_danmu_record(live_id).await)
} else {
Err(RecorderManagerError::NotFound { room_id })
}
}
async fn start_hls_server(
&self,
listener: TcpListener,
@@ -245,6 +254,18 @@ impl RecorderManager {
let recorders = recorders.clone();
let config = config.clone();
async move {
// handle cors preflight request
if req.method() == Method::OPTIONS {
return Ok::<_, Infallible>(
Response::builder()
.status(200)
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.body(Body::empty())
.unwrap(),
);
}
let cache_path = config.read().await.cache.clone();
let path = req.uri().path();
let path_segs: Vec<&str> = path.split('/').collect();
@@ -287,7 +308,7 @@ impl RecorderManager {
} else {
// try to find requested ts file in recorder's cache
// cache files are stored in {cache_dir}/{room_id}/{timestamp}/{ts_file}
let ts_file = format!("{}/{}", cache_path, path);
let ts_file = format!("{}/{}", cache_path, path.replace("%7C", "|"));
let recorder = recorders.get(&room_id);
if recorder.is_none() {
return Ok::<_, Infallible>(

View File

@@ -171,22 +171,26 @@
loading = true;
let new_cover = generateCover();
update_title(`切片生成中`);
let new_video = (await invoke("clip_range", {
roomId: room_id,
cover: new_cover,
ts: ts,
x: start,
y: end,
})) as VideoItem;
update_title(`切片生成成功`);
console.log("video file generatd:", video);
await get_video_list();
video_selected = new_video.id;
video = videos.find((v) => {
return v.value == new_video.id;
});
cover = new_video.cover;
loading = false;
try {
let new_video = (await invoke("clip_range", {
roomId: room_id,
cover: new_cover,
ts: ts,
x: start,
y: end,
})) as VideoItem;
update_title(`切片生成成功`);
console.log("video file generatd:", video);
await get_video_list();
video_selected = new_video.id;
video = videos.find((v) => {
return v.value == new_video.id;
});
cover = new_video.cover;
loading = false;
} catch (e) {
alert("Err generating clip: " + e);
}
}
async function do_post() {

View File

@@ -26,7 +26,9 @@
站直播流,并生成视频投稿的工具。
</p>
<p class="mt-4">
项目地址: <a href="https://github.com/Xinrea/bili-shadowreplay"
项目地址: <a
target="_blank"
href="https://github.com/Xinrea/bili-shadowreplay"
>https://github.com/Xinrea/bili-shadowreplay</a
>
</p>

View File

@@ -11,6 +11,11 @@
TableBodyCell,
Modal,
ButtonGroup,
SpeedDial,
Listgroup,
ListgroupItem,
Textarea,
Hr,
} from "flowbite-svelte";
import Image from "./Image.svelte";
import QRCode from "qrcode";
@@ -32,6 +37,9 @@
let oauth_key = "";
let check_interval = null;
let manualModal = false;
let cookie_str = "";
async function handle_qr() {
if (check_interval) {
clearInterval(check_interval);
@@ -52,7 +60,7 @@
async function check_qr() {
let qr_status: { code: number; cookies: string } = await invoke(
"get_qr_status",
{ qrcodeKey: oauth_key }
{ qrcodeKey: oauth_key },
);
if (qr_status.code == 0) {
clearInterval(check_interval);
@@ -61,6 +69,20 @@
addModal = false;
}
}
async function add_cookie() {
if (cookie_str == "") {
return;
}
try {
await invoke("add_account", { cookies: cookie_str });
await update_accounts();
cookie_str = "";
manualModal = false;
} catch (e) {
alert("Err adding cookie:" + e);
}
}
</script>
<div class="p-8 pt-12 h-full overflow-auto">
@@ -116,16 +138,23 @@
</Table>
</div>
<div class="fixed end-4 bottom-4">
<Button
pill={true}
class="!p-2"
on:click={() => {
addModal = true;
requestAnimationFrame(handle_qr);
}}><UserAddSolid class="w-8 h-8" /></Button
>
</div>
<SpeedDial defaultClass="absolute end-6 bottom-6" placement="top-end">
<Listgroup active>
<ListgroupItem
class="flex gap-2 md:px-5"
on:click={() => {
addModal = true;
requestAnimationFrame(handle_qr);
}}>扫码添加</ListgroupItem
>
<ListgroupItem
class="flex gap-2 md:px-5"
on:click={() => {
manualModal = true;
}}>手动添加</ListgroupItem
>
</Listgroup>
</SpeedDial>
<Modal
title="请使用 BiliBili App 扫码登录"
@@ -137,3 +166,20 @@
<canvas id="qr" />
</div>
</Modal>
<Modal
title="请粘贴 BiliBili 账号 Cookie"
bind:open={manualModal}
size="sm"
autoclose
>
<div class="flex flex-col justify-center items-center h-full">
<Textarea bind:value={cookie_str} />
<Button
class="mt-4"
on:click={() => {
add_cookie();
}}>添加</Button
>
</div>
</Modal>

View File

@@ -3,12 +3,38 @@
import { listen } from "@tauri-apps/api/event";
import type { AccountInfo, AccountItem } from "./db";
interface DanmuEntry {
ts: number;
content: string;
}
export let port;
export let room_id;
export let ts;
export let start = 0;
export let end = 0;
let show_detail = false;
let global_offset = 0;
// TODO get custom tag from shaka player instead of manual parsing
async function meta_parse() {
fetch(`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`)
.then((response) => response.text())
.then((m3u8Content) => {
const offsetRegex = /#EXT-X-OFFSET:(\d+)/;
const match = m3u8Content.match(offsetRegex);
if (match && match[1]) {
global_offset = parseInt(match[1], 10);
} else {
console.warn("No #EXT-X-OFFSET found");
}
})
.catch((error) => {
console.error("Error fetching M3U8 file:", error);
});
}
async function init() {
const video = document.getElementById("video") as HTMLVideoElement;
const ui = video["ui"];
@@ -26,9 +52,17 @@
// Attach player and UI to the window to make it easy to access in the JS console.
(window as any).player = player;
(window as any).ui = ui;
player.addEventListener("ended", async () => {
location.reload();
});
player.addEventListener("manifestloaded", (event) => {
console.log("Manifest loaded:", event);
});
try {
await player.load(
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`,
);
// This runs if the asynchronous load is successful.
console.log("The video has now been loaded!");
@@ -39,15 +73,12 @@
location.reload();
}
}
player.addEventListener("ended", async () => {
location.reload();
});
document.getElementsByClassName("shaka-overflow-menu-button")[0].remove();
document.getElementsByClassName("shaka-fullscreen-button")[0].remove();
// add self-defined element in shaka-bottom-controls.shaka-no-propagation (second seekbar)
const shakaBottomControls = document.querySelector(
".shaka-bottom-controls.shaka-no-propagation"
".shaka-bottom-controls.shaka-no-propagation",
);
const selfSeekbar = document.createElement("div");
selfSeekbar.className = "shaka-seek-bar shaka-no-propagation";
@@ -66,6 +97,37 @@
// add to shaka-spacer
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
let danmu_enabled = true;
// get danmaku record
let danmu_records: DanmuEntry[] = (await invoke("get_danmu_record", {
roomId: room_id,
ts: ts,
})) as DanmuEntry[];
console.log("danmu loaded:", danmu_records.length);
// history danmaku sender
setInterval(() => {
if (video.paused) {
return;
}
if (danmu_records.length == 0) {
return;
}
// using live source
if (isLive() && get_total() - video.currentTime <= 5) {
return;
}
const cur = Math.floor(
(video.currentTime + global_offset / 1000 + ts) * 1000,
);
console.log(new Date(cur).toString());
let danmus = danmu_records.filter((v) => {
return v.ts >= cur - 1000 && v.ts < cur;
});
danmus.forEach((v) => danmu_handler(v.content));
}, 1000);
if (isLive()) {
// add a account select
const accountSelect = document.createElement("select");
@@ -115,98 +177,104 @@
}
});
let danmu_enabled = true;
// create a danmaku toggle button
const danmakuToggle = document.createElement("button");
danmakuToggle.innerText = "弹幕已开启";
danmakuToggle.style.height = "30px";
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
danmakuToggle.style.color = "white";
danmakuToggle.style.border = "1px solid gray";
danmakuToggle.style.padding = "0 10px";
danmakuToggle.style.boxSizing = "border-box";
danmakuToggle.style.fontSize = "1em";
danmakuToggle.addEventListener("click", async () => {
danmu_enabled = !danmu_enabled;
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
// clear background color
danmakuToggle.style.backgroundColor = danmu_enabled
? "rgba(0, 128, 255, 0.5)"
: "rgba(255, 0, 0, 0.5)";
});
// create a area that overlay half top of the video, which shows danmakus floating from right to left
const overlay = document.createElement("div");
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.position = "absolute";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.pointerEvents = "none";
overlay.style.zIndex = "30";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.flexDirection = "column";
overlay.style.paddingTop = "10%";
// place overlay to the top of the video
video.parentElement.appendChild(overlay);
// Store the positions of the last few danmakus to avoid overlap
const danmakuPositions = [];
shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
// listen to danmaku event
listen("danmu:" + room_id, (event: { payload: string }) => {
listen("danmu:" + room_id, (event: { payload: DanmuEntry }) => {
// add into records
danmu_records.push(event.payload);
// if not enabled or playback is not keep up with live, ignore the danmaku
if (!danmu_enabled || get_total() - video.currentTime > 5) {
return;
}
const danmaku = document.createElement("p");
danmaku.style.position = "absolute";
// Calculate a random position for the danmaku
let topPosition;
let attempts = 0;
do {
topPosition = Math.random() * 30;
attempts++;
} while (
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
attempts < 10
);
// Record the position
danmakuPositions.push(topPosition);
if (danmakuPositions.length > 10) {
danmakuPositions.shift(); // Keep the last 10 positions
}
danmaku.style.top = `${topPosition}%`;
danmaku.style.right = "0";
danmaku.style.color = "white";
danmaku.style.fontSize = "1.2em";
danmaku.style.whiteSpace = "nowrap";
danmaku.style.transform = "translateX(100%)";
danmaku.style.transition = "transform 10s linear";
danmaku.style.pointerEvents = "none";
danmaku.style.margin = "0";
danmaku.style.padding = "0";
danmaku.style.zIndex = "500";
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
danmaku.innerText = event.payload;
overlay.appendChild(danmaku);
requestAnimationFrame(() => {
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
});
danmaku.addEventListener("transitionend", () => {
overlay.removeChild(danmaku);
});
danmu_handler(event.payload.content);
});
shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
shakaSpacer.appendChild(danmakuToggle);
}
// create a danmaku toggle button
const danmakuToggle = document.createElement("button");
danmakuToggle.innerText = "弹幕已开启";
danmakuToggle.style.height = "30px";
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
danmakuToggle.style.color = "white";
danmakuToggle.style.border = "1px solid gray";
danmakuToggle.style.padding = "0 10px";
danmakuToggle.style.boxSizing = "border-box";
danmakuToggle.style.fontSize = "1em";
danmakuToggle.addEventListener("click", async () => {
danmu_enabled = !danmu_enabled;
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
// clear background color
danmakuToggle.style.backgroundColor = danmu_enabled
? "rgba(0, 128, 255, 0.5)"
: "rgba(255, 0, 0, 0.5)";
});
// create a area that overlay half top of the video, which shows danmakus floating from right to left
const overlay = document.createElement("div");
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.position = "absolute";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.pointerEvents = "none";
overlay.style.zIndex = "30";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.flexDirection = "column";
overlay.style.paddingTop = "10%";
// place overlay to the top of the video
video.parentElement.appendChild(overlay);
// Store the positions of the last few danmakus to avoid overlap
const danmakuPositions = [];
function danmu_handler(content: string) {
const danmaku = document.createElement("p");
danmaku.style.position = "absolute";
// Calculate a random position for the danmaku
let topPosition;
let attempts = 0;
do {
topPosition = Math.random() * 30;
attempts++;
} while (
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
attempts < 10
);
// Record the position
danmakuPositions.push(topPosition);
if (danmakuPositions.length > 10) {
danmakuPositions.shift(); // Keep the last 10 positions
}
danmaku.style.top = `${topPosition}%`;
danmaku.style.right = "0";
danmaku.style.color = "white";
danmaku.style.fontSize = "1.2em";
danmaku.style.whiteSpace = "nowrap";
danmaku.style.transform = "translateX(100%)";
danmaku.style.transition = "transform 10s linear";
danmaku.style.pointerEvents = "none";
danmaku.style.margin = "0";
danmaku.style.padding = "0";
danmaku.style.zIndex = "500";
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
danmaku.innerText = content;
overlay.appendChild(danmaku);
requestAnimationFrame(() => {
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
});
danmaku.addEventListener("transitionend", () => {
overlay.removeChild(danmaku);
});
}
shakaSpacer.appendChild(danmakuToggle);
// create a playback rate select to of shaka-spacer
const playbackRateSelect = document.createElement("select");
playbackRateSelect.style.height = "30px";
@@ -317,7 +385,7 @@
const second_point = end / total;
// set background color for self-defined seekbar between first_point and second_point using linear-gradient
const seekbarContainer = selfSeekbar.querySelector(
".shaka-seek-bar-container.self-defined"
".shaka-seek-bar-container.self-defined",
) as HTMLElement;
seekbarContainer.style.background = `linear-gradient(to right, rgba(255, 255, 255, 0.4) ${
first_point * 100
@@ -332,6 +400,9 @@
}
requestAnimationFrame(updateSeekbar);
}
meta_parse();
// receive tauri emit
document.addEventListener("shaka-ui-loaded", init);