mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-25 04:22:24 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06de32ffe7 | ||
|
|
dd43074e46 | ||
|
|
93495e13db | ||
|
|
16950edae4 | ||
|
|
4af1203360 | ||
|
|
55b5bd1fd2 | ||
|
|
f0a7cf4ed0 | ||
|
|
62e7412abf | ||
|
|
275bf647d2 | ||
|
|
00af723be9 | ||
|
|
19da577836 | ||
|
|
bf3a2b469b | ||
|
|
bf31bfd099 | ||
|
|
d02fea99f2 | ||
|
|
2404bacb4e | ||
|
|
b6c274c181 | ||
|
|
f9b472aee7 | ||
|
|
45f277741b | ||
|
|
94179f59cd | ||
|
|
c7b550a3e3 | ||
|
|
fd51fd2387 | ||
|
|
23d1798ab6 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -32,4 +32,7 @@ src-tauri/tests/audio/*.srt
|
||||
.env
|
||||
|
||||
docs/.vitepress/cache
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/dist
|
||||
|
||||
*.debug.js
|
||||
*.debug.map
|
||||
|
||||
110
live_index.html
110
live_index.html
@@ -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>
|
||||
|
||||
@@ -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
17
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
_ => {}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
30
src/lib/SidebarItem.svelte
Normal file
30
src/lib/SidebarItem.svelte
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user