embystream 0.0.36

Another Emby streaming application (frontend/backend separation) written in Rust.
Documentation
use std::{
    env,
    fs::{OpenOptions, create_dir_all},
    io::Write,
    net::SocketAddr,
    path::{Path, PathBuf},
    str::FromStr,
};

use axum::Router;
use chrono::Utc;
use time::{OffsetDateTime, UtcOffset, macros::format_description};
use tokio::net::TcpListener;

use crate::cli::WebServeArgs;
use crate::{log_stream::global_log_stream, web::contracts::LogEntry};

use super::{
    api::{WebAppState, build_router},
    db::Database,
    error::WebError,
};

#[derive(Debug, Clone)]
pub struct WebRuntimeConfig {
    pub listen: SocketAddr,
    pub data_dir: PathBuf,
    pub tmdb_api_key: Option<String>,
    pub runtime_log_dir: PathBuf,
    pub stream_log_dir: PathBuf,
    pub executable_path: PathBuf,
    pub main_config_path: Option<PathBuf>,
}

pub fn to_runtime_config(
    args: WebServeArgs,
) -> Result<WebRuntimeConfig, WebError> {
    let listen_value = env::var("WEB_LISTEN")
        .ok()
        .or_else(|| env::var("web_listen").ok())
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(args.listen);
    let mut listen = SocketAddr::from_str(&listen_value).map_err(|_| {
        WebError::invalid_input("listen", "Web listen address is invalid.")
    })?;
    if let Some(port) = env::var("webui_port")
        .ok()
        .or_else(|| env::var("WEBUI_PORT").ok())
        .filter(|value| !value.trim().is_empty())
    {
        let port = u16::from_str(port.trim()).map_err(|_| {
            WebError::invalid_input("webui_port", "Web UI port is invalid.")
        })?;
        listen.set_port(port);
    }
    let data_dir = env::var("WEB_DATA_DIR")
        .ok()
        .or_else(|| env::var("web_data_dir").ok())
        .filter(|value| !value.trim().is_empty())
        .map(PathBuf::from)
        .unwrap_or(args.data_dir);
    let runtime_log_dir = env::var("WEB_RUNTIME_LOG_DIR")
        .ok()
        .or_else(|| env::var("web_runtime_log_dir").ok())
        .filter(|value| !value.trim().is_empty())
        .map(PathBuf::from)
        .unwrap_or(args.runtime_log_dir);
    let stream_log_dir = env::var("WEB_STREAM_LOG_DIR")
        .ok()
        .or_else(|| env::var("web_stream_log_dir").ok())
        .filter(|value| !value.trim().is_empty())
        .map(PathBuf::from)
        .unwrap_or(args.stream_log_dir);
    let tmdb_api_key = env::var("TMDB_API_KEY")
        .ok()
        .or_else(|| env::var("tmdb_api_key").ok())
        .filter(|value| !value.trim().is_empty())
        .or(args.tmdb_api_key);

    Ok(WebRuntimeConfig {
        listen,
        data_dir,
        tmdb_api_key: tmdb_api_key
            .map(|value| value.trim().to_string())
            .filter(|value| !value.is_empty()),
        runtime_log_dir,
        stream_log_dir,
        executable_path: std::env::current_exe()
            .unwrap_or_else(|_| PathBuf::from("embystream")),
        main_config_path: None,
    })
}

pub async fn serve_web_app(config: WebRuntimeConfig) -> Result<(), WebError> {
    let listen = config.listen;
    let db = Database::new(config.data_dir.clone());
    let bootstrap = db.initialize().await?;

    if let Some(admin) = bootstrap {
        print_web_startup_line(
            Some((
                &config.runtime_log_dir,
                format!(
                    "Bootstrap admin password generated for '{}'.",
                    admin.username
                ),
            )),
            "WARN",
            format!(
                "Bootstrap admin password for '{}': {}",
                admin.username, admin.password
            ),
        );
    }

    let runtime_log_dir = config.runtime_log_dir.clone();
    let state = WebAppState::new(db, config);
    let router: Router = build_router(state);

    let listener = TcpListener::bind(listen).await.map_err(WebError::from)?;
    print_web_startup_line(
        Some((
            &runtime_log_dir,
            format!("Web studio listening on http://{listen}"),
        )),
        "INFO",
        format!("Web studio listening on http://{listen}"),
    );

    axum::serve(
        listener,
        router.into_make_service_with_connect_info::<SocketAddr>(),
    )
    .await
    .map_err(WebError::from)
}

fn print_web_startup_line(
    persisted: Option<(&Path, String)>,
    level: &str,
    message: String,
) {
    let timer_fmt = format_description!(
        "[year]-[month padding:zero]-[day padding:zero] \
         [hour]:[minute]:[second].[subsecond digits:6]"
    );
    let time_offset =
        UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
    let timestamp = OffsetDateTime::now_utc()
        .to_offset(time_offset)
        .format(&timer_fmt)
        .unwrap_or_else(|_| "0000-00-00 00:00:00.000000".to_string());

    let line = format!("{timestamp} {level:<5} [WEB] {message}");
    println!("{line}");
    global_log_stream().publish(LogEntry {
        timestamp: Utc::now(),
        level: level.trim().to_string(),
        source: "runtime".to_string(),
        message: persisted
            .as_ref()
            .map(|(_, message)| message.clone())
            .unwrap_or_else(|| message.clone()),
    });

    if let Some((directory, persisted_message)) = persisted {
        let persisted_line =
            format!("{timestamp} {level:<5} [WEB] {persisted_message}");
        if let Err(error) = append_runtime_log_line(directory, &persisted_line)
        {
            eprintln!("Failed to append web runtime log: {error}");
        }
    }
}

fn append_runtime_log_line(
    directory: &Path,
    line: &str,
) -> Result<(), std::io::Error> {
    create_dir_all(directory)?;

    let path = directory.join("runtime.log");
    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
    writeln!(file, "{line}")?;
    Ok(())
}