ci/cd: docker package build

This commit is contained in:
Xinrea
2025-04-30 02:27:22 +08:00
parent 1625a5f889
commit 1b57beeea6
17 changed files with 721 additions and 423 deletions

39
.dockerignore Normal file
View File

@@ -0,0 +1,39 @@
# Dependencies
node_modules
.pnpm-store
.npm
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
# Build outputs
dist
build
target
*.log
# Version control
.git
.gitignore
# IDE and editor files
.idea
.vscode
*.swp
*.swo
.DS_Store
# Environment files
.env
.env.local
.env.*.local
# Debug files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Tauri specific
src-tauri/target
src-tauri/dist

50
.github/workflows/package.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Docker Build and Push
on:
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,format=long
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

70
Dockerfile Normal file
View File

@@ -0,0 +1,70 @@
# Build frontend
FROM node:20-bullseye AS frontend-builder
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Copy package files
COPY package.json yarn.lock ./
# Install dependencies with specific flags
RUN yarn install --frozen-lockfile
# Copy source files
COPY . .
# Build frontend
RUN yarn build
# Build Rust backend
FROM rust:1.85-slim AS rust-builder
WORKDIR /app
# Install required system dependencies
RUN apt-get update && apt-get install -y \
cmake \
pkg-config \
libssl-dev \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libclang-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy Rust project files
COPY src-tauri/Cargo.toml src-tauri/Cargo.lock ./src-tauri/
COPY src-tauri/src ./src-tauri/src
# Build Rust backend
WORKDIR /app/src-tauri
RUN cargo build --features headless --release
# Final stage
FROM debian:bullseye-slim AS final
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
libssl1.1 \
&& rm -rf /var/lib/apt/lists/*
# Copy built frontend
COPY --from=frontend-builder /app/dist ./dist
# Copy built Rust binary
COPY --from=rust-builder /app/src-tauri/target/release/bili-shadowreplay .
# Expose port
EXPOSE 3000
# Run the application
CMD ["./bili-shadowreplay"]

View File

@@ -6,9 +6,6 @@
![GitHub Release](https://img.shields.io/github/v/release/xinrea/bili-shadowreplay)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/xinrea/bili-shadowreplay/total)
> [!WARNING]
> v2.0.0 版本为重大更新,将不兼容 v1.x 版本的数据。
BiliBili ShadowReplay 是一个缓存直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
目前仅支持 B 站和抖音平台的直播。

View File

@@ -3,4 +3,5 @@
/target/
tmps
clips
data
data
config.toml

791
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,11 +9,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset", "tray-icon"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["blocking", "json"] }
serde_derive = "1.0.158"
@@ -37,27 +33,19 @@ urlencoding = "2.1.3"
log = "0.4.22"
simplelog = "0.12.2"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
tauri-plugin-http = "2"
tauri-utils = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-os = "2"
tauri-plugin-notification = "2"
rand = "0.8.5"
base64 = "0.21"
mime_guess = "2.0"
async-trait = "0.1.87"
whisper-rs = "0.14.2"
hound = "3.5.1"
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
uuid = { version = "1.4", features = ["v4"] }
axum = { version = "0.7", features = ["macros"] }
tower-http = { version = "0.5", features = ["cors", "fs", "limit"] }
futures-core = "0.3"
futures = "0.3"
tokio-util = { version = "0.7", features = ["io"] }
clap = { version = "4.5.37", features = ["derive"] }
[features]
# this feature is used for production builds or when `devPath` points to the filesystem
@@ -65,7 +53,6 @@ tokio-util = { version = "0.7", features = ["io"] }
custom-protocol = ["tauri/custom-protocol"]
cuda = ["whisper-rs/cuda"]
headless = []
default = ["headless"]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"
@@ -76,3 +63,18 @@ whisper-rs = { version = "0.14.2", default-features = false }
[target.'cfg(darwin)'.dependencies.whisper-rs]
version = "0.14.2"
features = ["metal"]
[target.'cfg(not(feature = "headless"))'.dependencies]
tauri = { version = "2", features = ["protocol-asset", "tray-icon"] }
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
tauri-plugin-http = "2"
tauri-utils = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-os = "2"
tauri-plugin-notification = "2"
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
[target.'cfg(not(feature = "headless"))'.build-dependencies]
tauri-build = { version = "2", features = [] }

View File

@@ -0,0 +1,14 @@
cache = "./cache"
output = "./output"
live_start_notify = true
live_end_notify = true
clip_notify = true
post_notify = true
auto_subtitle = false
whisper_model = ""
whisper_prompt = "这是一段中文 你们好"
clip_name_format = "[{room_id}][{live_id}][{title}][{created_at}].mp4"
[auto_generate]
enabled = false
encode_danmu = false

File diff suppressed because one or more lines are too long

View File

@@ -2372,6 +2372,12 @@
"const": "core:app:allow-set-app-theme",
"markdownDescription": "Enables the set_app_theme command without any pre-configured scope."
},
{
"description": "Enables the set_dock_visibility command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-set-dock-visibility",
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Enables the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -2432,6 +2438,12 @@
"const": "core:app:deny-set-app-theme",
"markdownDescription": "Denies the set_app_theme command without any pre-configured scope."
},
{
"description": "Denies the set_dock_visibility command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-set-dock-visibility",
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Denies the tauri_version command without any pre-configured scope.",
"type": "string",

View File

@@ -2372,6 +2372,12 @@
"const": "core:app:allow-set-app-theme",
"markdownDescription": "Enables the set_app_theme command without any pre-configured scope."
},
{
"description": "Enables the set_dock_visibility command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-set-dock-visibility",
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Enables the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -2432,6 +2438,12 @@
"const": "core:app:deny-set-app-theme",
"markdownDescription": "Denies the set_app_theme command without any pre-configured scope."
},
{
"description": "Denies the set_dock_visibility command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-set-dock-visibility",
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Denies the tauri_version command without any pre-configured scope.",
"type": "string",

View File

@@ -10,7 +10,9 @@ use crate::{recorder::PlatformType, recorder_manager::ClipRangeParams};
pub struct Config {
pub cache: String,
pub output: String,
#[serde(skip)]
pub webid: String,
#[serde(skip)]
pub webid_ts: i64,
pub live_start_notify: bool,
pub live_end_notify: bool,
@@ -26,6 +28,8 @@ pub struct Config {
pub clip_name_format: String,
#[serde(default = "default_auto_generate_config")]
pub auto_generate: AutoGenerateConfig,
#[serde(skip)]
pub config_path: String,
}
#[derive(Deserialize, Serialize, Clone)]
@@ -58,9 +62,7 @@ fn default_auto_generate_config() -> AutoGenerateConfig {
}
impl Config {
pub fn load() -> Self {
let app_dirs = AppDirs::new(Some("cn.vjoi.bili-shadowreplay"), false).unwrap();
let config_path = app_dirs.config_dir.join("Conf.toml");
pub fn load(config_path: &str) -> Self {
if let Ok(content) = std::fs::read_to_string(config_path) {
if let Ok(config) = toml::from_str(&content) {
return config;
@@ -69,18 +71,8 @@ impl Config {
let config = Config {
webid: "".to_string(),
webid_ts: 0,
cache: app_dirs
.cache_dir
.join("cache")
.to_str()
.unwrap()
.to_string(),
output: app_dirs
.data_dir
.join("output")
.to_str()
.unwrap()
.to_string(),
cache: "./cache".to_string(),
output: "./output".to_string(),
live_start_notify: true,
live_end_notify: true,
clip_notify: true,
@@ -90,6 +82,7 @@ impl Config {
whisper_prompt: "这是一段中文 你们好".to_string(),
clip_name_format: "[{room_id}][{live_id}][{title}][{created_at}].mp4".to_string(),
auto_generate: default_auto_generate_config(),
config_path: config_path.to_string(),
};
config.save();
config
@@ -97,11 +90,7 @@ impl Config {
pub fn save(&self) {
let content = toml::to_string(&self).unwrap();
let app_dirs = AppDirs::new(Some("cn.vjoi.bili-shadowreplay"), false).unwrap();
// Create app dirs if not exists
std::fs::create_dir_all(&app_dirs.config_dir).unwrap();
let config_path = app_dirs.config_dir.join("Conf.toml");
std::fs::write(config_path, content).unwrap();
std::fs::write(self.config_path.clone(), content).unwrap();
}
pub fn set_cache_path(&mut self, path: &str) {

View File

@@ -1031,7 +1031,7 @@ pub async fn start_api_server(state: State) {
let app = Router::new()
// Serve static files from dist directory
.nest_service("/", ServeDir::new("../dist"))
.nest_service("/", ServeDir::new("./dist"))
// Account commands
.route("/api/get_accounts", post(handler_get_accounts))
.route("/api/add_account", post(handler_add_account))

View File

@@ -18,6 +18,7 @@ mod subtitle_generator;
mod tray;
use archive_migration::try_rebuild_archives;
use clap::{arg, command, Parser};
use config::Config;
use database::Database;
use futures_core::future::BoxFuture;
@@ -129,23 +130,27 @@ impl MigrationSource<'static> for MigrationList {
}
#[cfg(feature = "headless")]
async fn setup_server_state() -> Result<State, Box<dyn std::error::Error>> {
async fn setup_server_state(args: Args) -> Result<State, Box<dyn std::error::Error>> {
use progress_manager::ProgressManager;
use progress_reporter::EventEmitter;
setup_logging(Path::new("bsr.log")).await?;
setup_logging(Path::new("./")).await?;
println!("Setting up server state...");
let client = Arc::new(BiliClient::new()?);
let config = Arc::new(RwLock::new(Config::load()));
let config = Arc::new(RwLock::new(Config::load(&args.config)));
let db = Arc::new(Database::new());
// connect to sqlite database
let conn_url = "sqlite:data/data_v2.db";
if !Sqlite::database_exists(conn_url).await.unwrap_or(false) {
Sqlite::create_database(conn_url).await?;
let conn_url = format!("sqlite:{}/data_v2.db", args.db);
// create db folder if not exists
if !Path::new(&args.db).exists() {
std::fs::create_dir_all(&args.db)?;
}
let db_pool: Pool<Sqlite> = Pool::connect(conn_url).await?;
if !Sqlite::database_exists(&conn_url).await.unwrap_or(false) {
Sqlite::create_database(&conn_url).await?;
}
let db_pool: Pool<Sqlite> = Pool::connect(&conn_url).await?;
let migrations = get_migrations();
let migrator = Migrator::new(MigrationList(migrations))
@@ -174,11 +179,16 @@ async fn setup_server_state() -> Result<State, Box<dyn std::error::Error>> {
#[cfg(not(feature = "headless"))]
async fn setup_app_state(app: &tauri::App) -> Result<State, Box<dyn std::error::Error>> {
use platform_dirs::AppDirs;
use progress_manager::ProgressManager;
use progress_reporter::EventEmitter;
println!("Setting up app state...");
let app_dirs = AppDirs::new(Some("cn.vjoi.bili-shadowreplay"), false).unwrap();
let config_path = app_dirs.config_dir.join("Conf.toml");
let client = Arc::new(BiliClient::new()?);
let config = Arc::new(RwLock::new(Config::load()));
let config = Arc::new(RwLock::new(Config::load(config_path.to_str().unwrap())));
let config_clone = config.clone();
let dbs = app.state::<tauri_plugin_sql::DbInstances>().inner();
let db = Arc::new(Database::new());
@@ -188,8 +198,11 @@ async fn setup_app_state(app: &tauri::App) -> Result<State, Box<dyn std::error::
let log_dir = app.path().app_log_dir()?;
setup_logging(&log_dir).await?;
let emitter = EventEmitter::new(app.handle().clone());
let recorder_manager = Arc::new(RecorderManager::new(
app.handle().clone(),
app.app_handle().clone(),
emitter,
db.clone(),
config.clone(),
));
@@ -407,10 +420,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[cfg(feature = "headless")]
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Path to the config file
#[arg(short, long, default_value_t = String::from("config.toml"))]
config: String,
/// Path to the database folder
#[arg(short, long, default_value_t = String::from("./data"))]
db: String,
}
#[cfg(feature = "headless")]
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let state = setup_server_state()
// get params from command line
let args = Args::parse();
let state = setup_server_state(args)
.await
.expect("Failed to setup server state");
http_server::start_api_server(state).await;

View File

@@ -2,13 +2,16 @@ use async_trait::async_trait;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::sync::LazyLock;
use tauri::AppHandle;
use tauri::Emitter;
use tokio::sync::broadcast;
use tokio::sync::RwLock;
use crate::progress_manager::Event;
use crate::recorder::danmu::DanmuEntry;
#[cfg(not(feature = "headless"))]
use {
crate::recorder::danmu::DanmuEntry,
tauri::{AppHandle, Emitter},
};
type CancelFlagMap = std::collections::HashMap<String, Arc<AtomicBool>>;
@@ -49,7 +52,7 @@ impl EventEmitter {
}
}
pub fn emit(&self, event: Event) {
pub fn emit(&self, event: &Event) {
#[cfg(not(feature = "headless"))]
{
match event {
@@ -65,7 +68,13 @@ impl EventEmitter {
}
Event::DanmuReceived { room, ts, content } => {
self.app_handle
.emit(&format!("danmu:{}", room), DanmuEntry { ts, content })
.emit(
&format!("danmu:{}", room),
DanmuEntry {
ts: *ts,
content: content.clone(),
},
)
.unwrap();
}
_ => {}
@@ -73,7 +82,7 @@ impl EventEmitter {
}
#[cfg(feature = "headless")]
let _ = self.sender.send(event);
let _ = self.sender.send(event.clone());
}
}
impl ProgressReporter {
@@ -81,7 +90,7 @@ impl ProgressReporter {
// if already exists, return
if CANCEL_FLAG_MAP.read().await.get(event_id).is_some() {
log::error!("Task already exists: {}", event_id);
emitter.emit(Event::ProgressFinished {
emitter.emit(&Event::ProgressFinished {
id: event_id.to_string(),
success: false,
message: "任务已经存在".to_string(),
@@ -106,14 +115,14 @@ impl ProgressReporter {
#[async_trait]
impl ProgressReporterTrait for ProgressReporter {
fn update(&self, content: &str) {
self.emitter.emit(Event::ProgressUpdate {
self.emitter.emit(&Event::ProgressUpdate {
id: self.event_id.clone(),
content: content.to_string(),
});
}
async fn finish(&self, success: bool, message: &str) {
self.emitter.emit(Event::ProgressFinished {
self.emitter.emit(&Event::ProgressFinished {
id: self.event_id.clone(),
success,
message: message.to_string(),

View File

@@ -403,7 +403,7 @@ impl BiliRecorder {
break;
}
if let WsStreamMessageType::DanmuMsg(msg) = msg {
self.emitter.emit(Event::DanmuReceived {
self.emitter.emit(&Event::DanmuReceived {
room: self.room_id,
ts: msg.timestamp as i64,
content: msg.msg.clone(),

View File

@@ -21,6 +21,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use tauri::AppHandle;
use tokio::fs::{remove_file, write};
use tokio::sync::broadcast;
use tokio::sync::RwLock;
@@ -58,6 +59,8 @@ pub enum RecorderEvent {
}
pub struct RecorderManager {
#[cfg(not(feature = "headless"))]
app_handle: AppHandle,
emitter: EventEmitter,
db: Arc<Database>,
config: Arc<RwLock<Config>>,
@@ -104,12 +107,15 @@ impl From<RecorderManagerError> for String {
impl RecorderManager {
pub fn new(
#[cfg(not(feature = "headless"))] app_handle: AppHandle,
emitter: EventEmitter,
db: Arc<Database>,
config: Arc<RwLock<Config>>,
) -> RecorderManager {
let (event_tx, _) = broadcast::channel(100);
let manager = RecorderManager {
#[cfg(not(feature = "headless"))]
app_handle,
emitter,
db,
config,
@@ -134,6 +140,8 @@ impl RecorderManager {
pub fn clone(&self) -> Self {
RecorderManager {
#[cfg(not(feature = "headless"))]
app_handle: self.app_handle.clone(),
emitter: self.emitter.clone(),
db: self.db.clone(),
config: self.config.clone(),