feat: introduce static server for file access (close #220) (#223)

This commit is contained in:
Xinrea
2025-11-09 22:18:26 +08:00
committed by GitHub
parent c97e0649a9
commit d3fe8dee2c
15 changed files with 165 additions and 119 deletions

View File

@@ -11,6 +11,11 @@ pub async fn get_config(state: state_type!()) -> Result<Config, ()> {
Ok(state.config.read().await.clone())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_static_port(state: state_type!()) -> Result<u16, ()> {
Ok(state.static_server.port)
}
#[cfg_attr(feature = "gui", tauri::command)]
#[allow(dead_code)]
pub async fn set_cache_path(state: state_type!(), cache_path: String) -> Result<(), String> {

View File

@@ -14,11 +14,11 @@ use crate::{
add_account, get_account_count, get_accounts, get_qr, get_qr_status, remove_account,
},
config::{
get_config, update_auto_generate, update_clip_name_format, update_danmu_ass_options,
update_notify, update_openai_api_endpoint, update_openai_api_key,
update_status_check_interval, update_subtitle_generator_type, update_subtitle_setting,
update_webhook_url, update_whisper_language, update_whisper_model,
update_whisper_prompt,
get_config, get_static_port, update_auto_generate, update_clip_name_format,
update_danmu_ass_options, update_notify, update_openai_api_endpoint,
update_openai_api_key, update_status_check_interval, update_subtitle_generator_type,
update_subtitle_setting, update_webhook_url, update_whisper_language,
update_whisper_model, update_whisper_prompt,
},
message::{delete_message, get_messages, read_message},
recorder::{
@@ -194,6 +194,15 @@ async fn handler_get_config(
Ok(Json(ApiResponse::success(config)))
}
async fn handler_get_static_port(
state: axum::extract::State<State>,
) -> Result<Json<ApiResponse<u16>>, ApiError> {
let static_port = get_static_port(state.0)
.await
.expect("Failed to get static port");
Ok(Json(ApiResponse::success(static_port)))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateStatusCheckIntervalRequest {
@@ -1772,12 +1781,10 @@ pub async fn start_api_server(state: State) {
log::info!("Running in readonly mode, some api routes are disabled");
}
let cache_path = state.config.read().await.cache.clone();
let output_path = state.config.read().await.output.clone();
app = app
// Config commands
.route("/api/get_config", post(handler_get_config))
.route("/api/get_static_port", post(handler_get_static_port))
// Message commands
.route("/api/get_messages", post(handler_get_messages))
.route("/api/read_message", post(handler_read_message))
@@ -1825,9 +1832,7 @@ pub async fn start_api_server(state: State) {
.route("/api/fetch", post(handler_fetch))
.route("/api/upload_file", post(handler_upload_file))
.route("/api/image/:video_id", get(handler_image_base64))
.route("/hls/*uri", get(handler_hls))
.nest_service("/output", ServeDir::new(output_path))
.nest_service("/cache", ServeDir::new(cache_path));
.route("/hls/*uri", get(handler_hls));
let websocket_layer = websocket::create_websocket_server(state.clone()).await;

View File

@@ -13,6 +13,7 @@ mod migration;
mod progress;
mod recorder_manager;
mod state;
mod static_server;
mod subtitle_generator;
mod task;
#[cfg(feature = "gui")]
@@ -422,7 +423,7 @@ impl MigrationSource<'static> for MigrationList {
async fn setup_server_state(args: Args) -> Result<State, Box<dyn std::error::Error>> {
use std::path::PathBuf;
use crate::task::TaskManager;
use crate::{static_server::start_static_server, task::TaskManager};
use progress::progress_manager::ProgressManager;
use progress::progress_reporter::EventEmitter;
@@ -480,6 +481,8 @@ async fn setup_server_state(args: Args) -> Result<State, Box<dyn std::error::Err
webhook_poster.clone(),
));
let static_server = Arc::new(start_static_server(config.clone()).await?);
let _ = try_rebuild_archives(&db, config.read().await.cache.clone().into()).await;
let _ = try_convert_live_covers(&db, config.read().await.cache.clone().into()).await;
let _ = try_convert_clip_covers(&db, config.read().await.output.clone().into()).await;
@@ -492,6 +495,7 @@ async fn setup_server_state(args: Args) -> Result<State, Box<dyn std::error::Err
webhook_poster,
recorder_manager,
task_manager,
static_server,
progress_manager,
readonly: args.readonly,
})
@@ -502,7 +506,7 @@ async fn setup_app_state(app: &tauri::App) -> Result<State, Box<dyn std::error::
use platform_dirs::AppDirs;
use progress::progress_reporter::EventEmitter;
use crate::task::TaskManager;
use crate::{static_server::start_static_server, task::TaskManager};
let log_dir = app.path().app_log_dir()?;
setup_logging(&log_dir).await?;
@@ -550,6 +554,8 @@ async fn setup_app_state(app: &tauri::App) -> Result<State, Box<dyn std::error::
webhook_poster.clone(),
));
let static_server = Arc::new(start_static_server(config.clone()).await?);
// try to rebuild archive table
let cache_path = config_clone.read().await.cache.clone();
let output_path = config_clone.read().await.output.clone();
@@ -566,6 +572,7 @@ async fn setup_app_state(app: &tauri::App) -> Result<State, Box<dyn std::error::
config,
recorder_manager,
task_manager,
static_server,
app_handle: app.handle().clone(),
webhook_poster,
})
@@ -623,6 +630,7 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
crate::handlers::account::get_qr_status,
crate::handlers::account::get_qr,
crate::handlers::config::get_config,
crate::handlers::config::get_static_port,
crate::handlers::config::set_cache_path,
crate::handlers::config::set_output_path,
crate::handlers::config::update_notify,

View File

@@ -4,6 +4,7 @@ use tokio::sync::RwLock;
use crate::config::Config;
use crate::database::Database;
use crate::recorder_manager::RecorderManager;
use crate::static_server::StaticServer;
use crate::task::TaskManager;
use crate::webhook::poster::WebhookPoster;
@@ -17,6 +18,7 @@ pub struct State {
pub webhook_poster: WebhookPoster,
pub recorder_manager: Arc<RecorderManager>,
pub task_manager: Arc<TaskManager>,
pub static_server: Arc<StaticServer>,
#[cfg(not(feature = "headless"))]
pub app_handle: tauri::AppHandle,
#[cfg(feature = "headless")]

View File

@@ -0,0 +1,57 @@
use crate::config::Config;
use axum::Router;
use std::{net::SocketAddr, sync::Arc};
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
pub struct StaticServer {
#[allow(dead_code)]
pub handle: JoinHandle<()>,
pub port: u16,
}
pub async fn start_static_server(
config: Arc<RwLock<Config>>,
) -> Result<StaticServer, Box<dyn std::error::Error>> {
let bind_addr = SocketAddr::from(([0, 0, 0, 0], 0));
log::info!("Starting static server binding to {}", bind_addr);
let listener = match tokio::net::TcpListener::bind(bind_addr).await {
Ok(listener) => {
match listener.local_addr() {
Ok(addr) => log::info!("Static server listening on http://{}", addr),
Err(e) => log::warn!("Unable to determine listening address: {}", e),
}
listener
}
Err(e) => {
log::error!("Failed to bind static server: {}", e);
log::error!("Please check if the port is already in use or try a different port");
return Err(e.into());
}
};
let port = listener.local_addr().unwrap().port();
let output_path = config.read().await.output.clone();
let cache_path = config.read().await.cache.clone();
let handle = tokio::spawn(async move {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let router = Router::new()
.layer(cors)
.nest_service("/output", ServeDir::new(output_path))
.nest_service("/cache", ServeDir::new(cache_path));
if let Err(e) = axum::serve(listener, router).await {
log::error!("Server error: {}", e);
}
});
Ok(StaticServer { handle, port })
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { invoke, convertFileSrc, get_cover } from "./lib/invoker";
import { invoke, get_static_url } from "./lib/invoker";
import { onMount } from "svelte";
import VideoPreview from "./lib/components/VideoPreview.svelte";
import type { Config, VideoItem } from "./lib/interface";
@@ -36,7 +36,7 @@
id: v.id,
value: v.id,
name: v.file,
file: await convertFileSrc(v.file),
file: await get_static_url("output", v.file),
cover: v.cover,
};
})
@@ -59,7 +59,7 @@
async function handleVideoChange(newVideo: VideoItem) {
if (newVideo) {
if (newVideo.cover && newVideo.cover.trim() !== "") {
newVideo.cover = await get_cover("output", newVideo.cover);
newVideo.cover = await get_static_url("output", newVideo.cover);
} else {
newVideo.cover = "";
}
@@ -76,7 +76,7 @@
id: v.id,
value: v.id,
name: v.file,
file: await convertFileSrc(v.file),
file: await get_static_url("output", v.file),
cover: v.cover,
};
})

View File

@@ -3,10 +3,9 @@
invoke,
set_title,
TAURI_ENV,
convertFileSrc,
listen,
log,
get_cover,
get_static_url,
} from "./lib/invoker";
import Player from "./lib/components/Player.svelte";
import type { RecordItem } from "./lib/db";
@@ -15,8 +14,6 @@
type VideoItem,
type Config,
type Marker,
type ProgressUpdate,
type ProgressFinished,
type DanmuEntry,
clipRange,
generateEventId,
@@ -264,7 +261,7 @@
id: v.id,
value: v.id,
name: v.file,
file: await convertFileSrc(v.file),
file: await get_static_url("output", v.file),
cover: v.cover,
};
})
@@ -281,7 +278,7 @@
return v.value == id;
});
if (target_video) {
target_video.cover = await get_cover("output", target_video.cover);
target_video.cover = await get_static_url("output", target_video.cover);
}
selected_video = target_video;
}
@@ -342,7 +339,7 @@
fix_encoding,
})) as VideoItem;
await get_video_list();
new_video.cover = await get_cover("output", new_video.cover);
new_video.cover = await get_static_url("output", new_video.cover);
video_selected = new_video.id;
selected_video = videos.find((v) => {
return v.value == new_video.id;

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { invoke, get_cover } from "../invoker";
import { invoke, get_static_url } from "../invoker";
import type { RecordItem } from "../db";
import { fade, scale } from "svelte/transition";
import { X, FileVideo } from "lucide-svelte";
@@ -34,7 +34,7 @@
// 处理封面
for (const archive of sameParentArchives) {
archive.cover = await get_cover(
archive.cover = await get_static_url(
"cache",
`${archive.platform}/${archive.room_id}/${archive.live_id}/cover.jpg`
);

View File

@@ -34,7 +34,7 @@
listen,
log,
close_window,
get_cover,
get_static_url,
} from "../invoker";
import { onDestroy, onMount } from "svelte";
import { listen as tauriListen } from "@tauri-apps/api/event";

View File

@@ -1,7 +1,6 @@
import { invoke as tauri_invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { fetch as tauri_fetch } from "@tauri-apps/plugin-http";
import { convertFileSrc as tauri_convert } from "@tauri-apps/api/core";
import { listen as tauri_listen } from "@tauri-apps/api/event";
import { open as tauri_open } from "@tauri-apps/plugin-shell";
import { onOpenUrl as tauri_onOpenUrl } from "@tauri-apps/plugin-deep-link";
@@ -125,84 +124,23 @@ async function set_title(title: string) {
document.title = title;
}
async function convertFileSrc(filePath: string) {
if (TAURI_ENV) {
// 在客户端模式下需要获取config来构建绝对路径
try {
const config = (await invoke("get_config")) as any;
const absolutePath = `${config.output}/${filePath}`;
return tauri_convert(absolutePath);
} catch (error) {
console.error("Failed to get config for file path conversion:", error);
return tauri_convert(filePath);
}
}
// 在headless模式下保持完整的相对路径
return `${ENDPOINT}/output/${filePath}`;
}
let STATIC_PORT = 0;
let config: Config | null = null;
const coverCache: Map<string, string> = new Map();
async function get_cover(coverType: string, coverPath: string) {
async function get_static_url(base: string, path: string) {
if (config === null) {
config = (await invoke("get_config")) as any;
}
if (TAURI_ENV) {
if (coverType === "cache") {
const absolutePath = `${config.cache}/${coverPath}`;
if (coverCache.has(absolutePath)) {
return coverCache.get(absolutePath);
}
// 检查文件是否存在
try {
const exists = (await invoke("file_exists", {
path: absolutePath,
})) as boolean;
if (!exists) {
log.error("Cover file not found:", absolutePath);
return "/imgs/bilibili.png"; // 返回默认封面
}
const url = tauri_convert(absolutePath);
coverCache.set(absolutePath, url);
return url;
} catch (e) {
log.error("Failed to check cover file existence:", e);
return "/imgs/bilibili.png"; // 返回默认封面
}
}
if (coverType === "output") {
const absolutePath = `${config.output}/${coverPath}`;
if (coverCache.has(absolutePath)) {
return coverCache.get(absolutePath);
}
// 检查文件是否存在
try {
const exists = (await invoke("file_exists", {
path: absolutePath,
})) as boolean;
if (!exists) {
return "/imgs/bilibili.png"; // 返回默认封面
}
const url = tauri_convert(absolutePath);
coverCache.set(absolutePath, url);
return url;
} catch (e) {
log.error("Failed to check cover file existence:", e);
return "/imgs/bilibili.png"; // 返回默认封面
}
}
// exception
throw new Error(`Invalid cover type: ${coverType}`);
if (STATIC_PORT === 0) {
STATIC_PORT = await invoke("get_static_port");
}
let staticUrl = `http://localhost:${STATIC_PORT}`;
if (!TAURI_ENV) {
// replace port in ENDPOINT
staticUrl = ENDPOINT.replace(/:\d+/, `:${STATIC_PORT}`);
}
return `${ENDPOINT}/${coverType}/${coverPath}`;
return `${staticUrl}/${base}/${path}`;
}
let socket: Socket | null = null;
@@ -345,12 +283,11 @@ export {
get,
set_title,
TAURI_ENV,
convertFileSrc,
ENDPOINT,
listen,
open,
log,
close_window,
onOpenUrl,
get_cover,
get_static_url,
};

View File

@@ -10,6 +10,8 @@
accounts: [],
};
let avatar_cache: Map<string, string> = new Map();
async function update_accounts() {
let new_account_info = (await invoke("get_accounts")) as AccountInfo;
for (const account of new_account_info.accounts) {
@@ -17,9 +19,15 @@
account.avatar = platform_avatar(account.platform);
continue;
}
if (avatar_cache.has(account.avatar)) {
account.avatar = avatar_cache.get(account.avatar);
continue;
}
const avatar_response = await get(account.avatar);
const avatar_blob = await avatar_response.blob();
account.avatar = URL.createObjectURL(avatar_blob);
const avatar_url = URL.createObjectURL(avatar_blob);
avatar_cache.set(account.avatar, avatar_url);
account.avatar = avatar_url;
}
account_info = new_account_info;
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { invoke, get_cover } from "../lib/invoker";
import { invoke, get_static_url } from "../lib/invoker";
import type { RecordItem } from "../lib/db";
import { onMount } from "svelte";
import {
@@ -95,7 +95,7 @@
// 处理封面
for (const archive of roomArchives) {
archive.cover = await get_cover(
archive.cover = await get_static_url(
"cache",
`${archive.platform}/${archive.room_id}/${archive.live_id}/cover.jpg`
);

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { invoke, TAURI_ENV, convertFileSrc, get_cover } from "../lib/invoker";
import { invoke, TAURI_ENV, get_static_url } from "../lib/invoker";
import type { VideoItem } from "../lib/interface";
import ImportVideoDialog from "../lib/components/ImportVideoDialog.svelte";
import { onMount, onDestroy, tick } from "svelte";
@@ -173,7 +173,7 @@
const tempVideos = await invoke<VideoItem[]>("get_all_videos");
for (const video of tempVideos) {
video.cover = await get_cover("output", video.cover);
video.cover = await get_static_url("output", video.cover);
}
for (const video of tempVideos) {
@@ -298,7 +298,7 @@
}
}
function getRoomUrl(platform: string | undefined, roomId: number) {
function getRoomUrl(platform: string | undefined, roomId: string) {
if (!platform) return null;
switch (platform.toLowerCase()) {
case "bilibili":
@@ -393,7 +393,7 @@
async function exportVideo(video: VideoItem) {
// download video
const video_url = await convertFileSrc(video.file);
const video_url = await get_static_url("output", video.file);
const video_name = video.title;
const a = document.createElement("a");
a.href = video_url;
@@ -428,7 +428,7 @@
// 更新筛选后的视频列表
const index = filteredVideos.findIndex(
(v) => v.id === videoToEditNote.id,
(v) => v.id === videoToEditNote.id
);
if (index !== -1) {
filteredVideos[index].note = editingNote;

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { invoke, open, onOpenUrl, get_cover, get } from "../lib/invoker";
import { invoke, open, onOpenUrl, get_static_url, get } from "../lib/invoker";
import { message } from "@tauri-apps/plugin-dialog";
import { fade, scale } from "svelte/transition";
import { Dropdown, DropdownItem } from "flowbite-svelte";
@@ -57,6 +57,32 @@
}
}
let avatar_cache: Map<string, string> = new Map();
async function get_avatar_url(user_id: string, url: string) {
if (avatar_cache.has(user_id)) {
return avatar_cache.get(user_id);
}
console.log("get avatar url:", url);
const response = await get(url);
const blob = await response.blob();
const avatar_url = URL.createObjectURL(blob);
avatar_cache.set(user_id, avatar_url);
return avatar_url;
}
let image_cache: Map<string, string> = new Map();
async function get_image_url(url: string) {
if (image_cache.has(url)) {
return image_cache.get(url);
}
console.log("get image url:", url);
const response = await get(url);
const blob = await response.blob();
const cover_url = URL.createObjectURL(blob);
image_cache.set(url, cover_url);
return cover_url;
}
async function update_summary() {
let new_summary = (await invoke("get_recorder_list")) as RecorderList;
room_count = new_summary.count;
@@ -77,17 +103,18 @@
// process room cover
for (const room of new_summary.recorders) {
if (room.room_info.room_cover != "") {
const cover_response = await get(room.room_info.room_cover);
const cover_blob = await cover_response.blob();
room.room_info.room_cover = URL.createObjectURL(cover_blob);
room.room_info.room_cover = await get_image_url(
room.room_info.room_cover
);
} else {
room.room_info.room_cover = default_cover(room.room_info.platform);
}
if (room.user_info.user_avatar != "") {
const avatar_response = await get(room.user_info.user_avatar);
const avatar_blob = await avatar_response.blob();
room.user_info.user_avatar = URL.createObjectURL(avatar_blob);
room.user_info.user_avatar = await get_avatar_url(
room.user_info.user_id,
room.user_info.user_avatar
);
} else {
room.user_info.user_avatar = default_avatar(room.room_info.platform);
}
@@ -145,7 +172,7 @@
})) as RecordItem[];
for (const archive of new_archives) {
archive.cover = await get_cover(
archive.cover = await get_static_url(
"cache",
`${archive.platform}/${archive.room_id}/${archive.live_id}/cover.jpg`
);

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { get_cover, invoke } from "../lib/invoker";
import { get_static_url, invoke } from "../lib/invoker";
import type { RecorderList, DiskInfo } from "../lib/interface";
import type { RecordItem } from "../lib/db";
const INTERVAL = 5000;
@@ -90,7 +90,7 @@
})) as RecordItem[];
for (const record of newRecords) {
record.cover = await get_cover(
record.cover = await get_static_url(
"cache",
`${record.platform}/${record.room_id}/${record.live_id}/cover.jpg`
);