Files
tuliprox/backend/src/api/endpoints/websocket_api.rs
T
euzuandGitHub fc0f1f32aa Enhanced Metadata Resolution, Stream Analysis & Improved STRM-Output. Added connection priority management.
**Description:**
This PR introduces a major overhaul of how streams are analyzed and connections are managed. It integrates `ffprobe` for deep stream analysis, adds a background task queue for metadata resolution to prevent UI blocking, introduces a connection priority system, and adds support for Live TV probing.

### 🚀 Key Features

*   **FFprobe Integration & Live TV Probing**:
    *   Automatically probes streams (VOD/Series **and now Live TV**) to determine resolution, codecs (HEVC/AV1), dynamic range (HDR/DV), bit depth, and audio channels.
    *   **Safe Probing**: Probing strictly respects the provider's `max_connections` limit.
    *   Docker images now include static `ffmpeg/ffprobe` binaries.

*   **Smart Connection Management (Priority System)**:
    *   Introduced a priority system for connections to manage provider limits intelligently.
    *   **Preemption**: Users with higher priority can preempt (kick) lower priority connections when provider slots are full.
    *   **Background Queue**: Metadata resolution and stream analysis run as background tasks (Priority 0). They only run when connection slots are idle and yield immediately if a real user needs to stream.

*   **Enhanced Metadata Resolution**:
    *   **Fallback Logic**: If a provider is missing the TMDB ID or Release Date, the system resolves them via the TMDB API.
    *   **Smart Title Cleaning**: Implemented a centralized cleaner to strip common IPTV prefixes before attempting metadata matches or generating filenames, significantly improving match rates.

*   **Improved Output**:
    *   **Quality Tags**: Filenames in STRM files now include detailed tags (e.g., `Movie - [4K HEVC HDR].strm`).
    *   **Flat Grouping**: `flat: true` now correctly groups multi-version movies into single folders based on TMDB ID.
    *   **Global Series Date**: Ensures consistent series folder naming even if episodes have different years.
2026-02-12 15:06:57 +01:00

337 lines
13 KiB
Rust

use crate::api::endpoints::v1_api::create_status_check;
use crate::api::model::AppState;
use crate::api::model::EventMessage;
use crate::auth::{verify_token_admin, verify_token_user};
use axum::extract::ws::CloseFrame;
use axum::{extract::ws::{Message, WebSocket, WebSocketUpgrade},response::IntoResponse};
use log::{error, trace};
use shared::model::{ProtocolHandler, ProtocolHandlerMemory, ProtocolMessage, UserCommand, UserRole, WsCloseCode, PROTOCOL_VERSION};
use std::sync::Arc;
use shared::utils::{concat_path_leading_slash, default_kick_secs};
// WebSocket upgrade handler
async fn websocket_handler(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
trace!("Websocket connected");
ws.on_upgrade(move |socket| handle_socket(socket, app_state, false))
}
// WebSocket upgrade handler
async fn websocket_handler_auth(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
trace!("Websocket connected");
ws.on_upgrade(move |socket| handle_socket(socket, app_state, true))
}
pub fn ws_api_register(web_auth_enabled: bool, web_ui_path: &str) -> axum::Router<Arc<AppState>> {
if web_auth_enabled {
axum::Router::new().route(
&concat_path_leading_slash(web_ui_path, "ws"),
axum::routing::get(websocket_handler_auth),
)
} else {
axum::Router::new().route(
&concat_path_leading_slash(web_ui_path, "ws"),
axum::routing::get(websocket_handler),
)
}
}
#[inline]
fn verify_auth_admin_token(auth_token: &str, secret_key: Option<&Vec<u8>>) -> bool {
match secret_key.as_ref() {
None => false,
Some(key) => verify_token_admin(auth_token, key.as_slice()),
}
}
#[inline]
fn verify_auth_user_token(auth_token: &str, secret_key: Option<&Vec<u8>>) -> bool {
match secret_key.as_ref() {
None => false,
Some(key) => verify_token_user(auth_token, key.as_slice()),
}
}
fn get_secret_key(app_state: &AppState, auth: bool) -> Option<Vec<u8>> {
if !auth {
return None;
}
app_state
.app_config
.config
.load()
.web_ui
.as_ref()
.and_then(|c| c.auth.as_ref())
.map(|c| {
let secret_key: &[u8] = c.secret.as_ref();
secret_key.to_vec()
})
}
async fn handle_handshake(msg: Message, socket: &mut WebSocket, version: u8) -> Result<(), String> {
if let Message::Binary(bytes) = msg {
if bytes.len() == 1 {
let client_version = bytes[0];
if client_version == version {
socket
.send(Message::binary(bytes))
.await
.map_err(|e| e.to_string())?;
return Ok(());
}
error!("Protocol Version mismatch: server={version}, client={client_version}");
}
}
let _ = socket
.send(Message::Close(Some(CloseFrame {
code: WsCloseCode::Protocol.code(),
reason: "Unsupported protocol".into(),
})))
.await;
Err("Protocol version mismatch".into())
}
async fn handle_protocol_message(
msg: Message,
mem: &mut ProtocolHandlerMemory,
app_state: &Arc<AppState>,
auth_required: bool,
secret_key: Option<&Vec<u8>>,
) -> Option<ProtocolMessage> {
if let Message::Binary(bytes) = msg {
match ProtocolMessage::from_bytes(bytes) {
Ok(ProtocolMessage::Auth(auth_token)) => {
mem.token = None;
if !auth_required || verify_auth_admin_token(&auth_token, secret_key) {
mem.role = UserRole::Admin;
mem.token = Some(auth_token);
Some(ProtocolMessage::Authorized)
} else if verify_auth_user_token(&auth_token, secret_key) {
mem.role = UserRole::User;
mem.token = Some(auth_token);
Some(ProtocolMessage::Authorized)
} else {
Some(ProtocolMessage::Unauthorized)
}
},
Ok(ProtocolMessage::StatusRequest(auth_token)) => {
if !auth_required || verify_auth_admin_token(&auth_token, secret_key) {
mem.role = UserRole::Admin;
mem.token = Some(auth_token);
let status = create_status_check(app_state).await;
Some(ProtocolMessage::StatusResponse(status))
} else {
Some(ProtocolMessage::Unauthorized)
}
},
Ok(ProtocolMessage::UserAction(cmd)) => {
if let Some(token) = mem.token.as_ref() {
if !auth_required || verify_auth_admin_token(token, secret_key) {
Some(ProtocolMessage::UserActionResponse(handle_user_action(app_state, cmd).await))
} else {
Some(ProtocolMessage::UserActionResponse(false))
}
} else {
Some(ProtocolMessage::UserActionResponse(false))
}
},
Ok(ProtocolMessage::ActiveProviderCountRequest(auth_token)) => {
if !auth_required || verify_auth_admin_token(&auth_token, secret_key) {
mem.role = UserRole::Admin;
mem.token = Some(auth_token);
let connections = app_state.active_provider.get_provider_connections_count().await;
Some(ProtocolMessage::ActiveProviderCountResponse(connections))
} else {
Some(ProtocolMessage::Unauthorized)
}
},
Ok(_) => {
trace!("Unexpected protocol message after handshake");
None
}
Err(e) => {
error!("Invalid websocket message: {e}");
Some(ProtocolMessage::Error(format!(
"Invalid websocket message: {e}"
)))
}
}
} else {
None
}
}
async fn handle_incoming_message(
result: Result<Message, axum::Error>,
socket: &mut WebSocket,
handler: &mut ProtocolHandler,
app_state: &Arc<AppState>,
auth_required: bool,
secret_key: Option<&Vec<u8>>,
) -> Result<(), String> {
let msg = result.map_err(|e| e.to_string())?;
match handler {
ProtocolHandler::Version(version) => {
handle_handshake(msg, socket, *version).await?;
*handler = ProtocolHandler::Default(ProtocolHandlerMemory::default());
Ok(())
}
ProtocolHandler::Default(mem) => {
let msg = handle_protocol_message(msg, mem, app_state, auth_required, secret_key).await;
match msg {
None => Ok(()),
Some(protocol_msg) => {
let bytes = match protocol_msg.to_bytes() {
Ok(bytes) => bytes,
Err(err) => ProtocolMessage::Error(err.to_string())
.to_bytes()
.map_err(|e| e.to_string())?,
};
Ok(socket
.send(Message::Binary(bytes))
.await
.map_err(|e| e.to_string())?)
}
}
}
}
}
async fn handle_event_message(socket: &mut WebSocket, event: EventMessage, handler: &ProtocolHandler) -> Result<(), String> {
match handler {
ProtocolHandler::Version(_) => {},
ProtocolHandler::Default(mem) => {
if mem.role.is_admin() {
match event {
EventMessage::ServerError(error) => {
let msg = ProtocolMessage::ServerError(error)
.to_bytes()
.map_err(|e| e.to_string())?;
socket
.send(Message::Binary(msg))
.await
.map_err(|e| format!("Server Error event: {e} "))?;
}
EventMessage::ActiveUser(event) => {
let msg = ProtocolMessage::ActiveUserResponse(event)
.to_bytes()
.map_err(|e| e.to_string())?;
socket
.send(Message::Binary(msg))
.await
.map_err(|e| format!("Active user connection change event: {e} "))?;
}
EventMessage::ActiveProvider(provider, connections) => {
let msg = ProtocolMessage::ActiveProviderResponse(provider, connections)
.to_bytes()
.map_err(|e| e.to_string())?;
socket
.send(Message::Binary(msg))
.await
.map_err(|e| format!("Provider connection change event: {e} "))?;
}
EventMessage::ConfigChange(config) => {
let msg = ProtocolMessage::ConfigChangeResponse(config)
.to_bytes()
.map_err(|e| e.to_string())?;
socket
.send(Message::Binary(msg))
.await
.map_err(|e| format!("Configuration files change event: {e} "))?;
}
EventMessage::PlaylistUpdate(state) => {
let msg = ProtocolMessage::PlaylistUpdateResponse(state)
.to_bytes()
.map_err(|e| e.to_string())?;
socket
.send(Message::Binary(msg))
.await
.map_err(|e| format!("Playlist update event: {e} "))?;
}
EventMessage::PlaylistUpdateProgress(target, msg) => {
let msg = ProtocolMessage::PlaylistUpdateProgressResponse(target, msg)
.to_bytes()
.map_err(|e| e.to_string())?;
socket
.send(Message::Binary(msg))
.await
.map_err(|e| format!("Playlist update progress event: {e} "))?;
}
EventMessage::SystemInfoUpdate(system_info) => {
let msg = ProtocolMessage::SystemInfoResponse(system_info)
.to_bytes()
.map_err(|e| e.to_string())?;
socket
.send(Message::Binary(msg))
.await
.map_err(|e| format!("System info event: {e} "))?;
}
EventMessage::LibraryScanProgress(summary) => {
let msg = ProtocolMessage::LibraryScanProgressResponse(summary)
.to_bytes()
.map_err(|e| e.to_string())?;
socket
.send(Message::Binary(msg))
.await
.map_err(|e| format!("Library scan progress event: {e} "))?;
}
EventMessage::InputMetadataUpdatesCompleted(_) => {
// Internal event, ignore for websocket clients
}
}
}
}
}
Ok(())
}
// WebSocket communication logic
async fn handle_socket(mut socket: WebSocket, app_state: Arc<AppState>, auth_required: bool) {
let secret_key = get_secret_key(&app_state, auth_required);
let mut event_rx = app_state.event_manager.get_event_channel();
let mut handler = ProtocolHandler::Version(PROTOCOL_VERSION);
loop {
tokio::select! {
maybe_msg = socket.recv() => {
if let Some(msg) = maybe_msg {
if let Err(e) = handle_incoming_message(msg, &mut socket, &mut handler, &app_state, auth_required, secret_key.as_ref()).await {
trace!("WebSocket message handling error: {e}");
break;
}
} else {
break;
}
}
Ok(event) = event_rx.recv() => {
if let Err(e) = handle_event_message(&mut socket, event, &handler).await {
trace!("Failed to send ws event: {e}");
break;
}
}
}
}
}
async fn handle_user_action(app_state: &Arc<AppState>, cmd: UserCommand) -> bool {
match cmd {
UserCommand::Kick(addr, virtual_id, _secs) => {
// secs could be later used for different kick configurations. Currently, we only have 1.
let kick_secs = app_state.app_config.config.load().web_ui.as_ref().map_or_else(default_kick_secs, |wc| wc.kick_secs);
app_state.connection_manager.kick_connection(&addr, virtual_id, kick_secs).await
}
}
}