Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "kairo"
version = "4.6.0"
version = "4.7.0"
edition = "2021"
license = "MIT"
description = "Standalone Discord audio sending node, written in Rust. Drop-in Lavalink v4 compatible."
Expand All @@ -12,6 +12,7 @@ libc = "0.2"
mimalloc = "0.1.48"
perf_monitor = "0.2.1"
rand = "0.8"
semver = "1"
serde_json = "1"
serde_yaml = "0.9"
tower-service = "0.3"
Expand All @@ -20,7 +21,7 @@ tracing-appender = "0.2"

[dependencies.lyrics]
git = "https://github.com/bongo-devs/lyrics"
tag = "v0.2.4"
tag = "v0.2.5"

[dependencies.player]
git = "https://github.com/bongo-devs/player"
Expand All @@ -34,6 +35,11 @@ tag = "v0.1.12"
git = "https://github.com/bongo-devs/voice"
tag = "v0.1.0"

[dependencies.reqwest]
version = "0.12"
default-features = false
features = ["rustls-tls", "json"]

[dependencies.tokio]
version = "1.52"
features = ["tracing", "macros", "signal", "rt-multi-thread"]
Expand Down
1 change: 1 addition & 0 deletions application.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ logging:
symphonia_bundle_mp3: error # silences harmless VBR main_data_begin warnings
format: compact # compact | pretty | json
color: true
banner: true # print the startup banner (skipped when format is json)
timestamps: true
showTarget: true # prefix lines with the module name
request: # REST request logging
Expand Down
5 changes: 5 additions & 0 deletions src/config/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ pub struct LoggingConfig {
pub format: LogFormat,
/// Colourise console output. Ignored for the file sink.
pub color: bool,
/// Print the startup banner to the console. Ignored when [`format`] is JSON.
///
/// [`format`]: Self::format
pub banner: bool,
/// Prefix each line with a `HH:MM:SS` timestamp.
pub timestamps: bool,
/// Include the module target in each line.
Expand All @@ -33,6 +37,7 @@ impl Default for LoggingConfig {
levels: HashMap::new(),
format: LogFormat::Compact,
color: true,
banner: true,
timestamps: true,
show_target: true,
file: None,
Expand Down
5 changes: 5 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ async fn main() -> ExitCode {

LazyLock::force(&CONFIG);

kairo::utils::banner::print(&CONFIG.logging);

// The guard flushes what the file sink buffered when dropped, so it is held for all of `main`.
let _logging = kairo::utils::init(&CONFIG.logging);

// Fire and forget: it logs an upgrade notice if one exists and stays out of the way otherwise.
tokio::spawn(kairo::utils::update::check(&CONFIG.logging));

let bind = format!("{}:{}", CONFIG.server.address, CONFIG.server.port);
let http2 = CONFIG.server.http2.enabled;

Expand Down
6 changes: 6 additions & 0 deletions src/utils/ansi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//! The ANSI escapes the console output shares, so the banner and notices read as one palette.

pub const ACCENT: &str = "\x1b[38;5;44m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const RESET: &str = "\x1b[0m";
46 changes: 46 additions & 0 deletions src/utils/banner.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! The startup banner printed to the console before logging begins.

use crate::config::{LogFormat, LoggingConfig};
use crate::utils::ansi::{ACCENT, BOLD, DIM, RESET};

const LOGO: &str = "\
██╗ ██╗ █████╗ ██╗██████╗ ██████╗
██║ ██╔╝██╔══██╗██║██╔══██╗██╔═══██╗
█████╔╝ ███████║██║██████╔╝██║ ██║
██╔═██╗ ██╔══██║██║██╔══██╗██║ ██║
██║ ██╗██║ ██║██║██║ ██║╚██████╔╝
╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═════╝";

const TAGLINE: &str = "Standalone Discord audio sending node · Lavalink v4 compatible";

/// Print the logo, version and a short facts line to stdout.
///
/// Written directly rather than through `tracing` so it lands before the subscriber is installed
/// and never carries a log prefix. Skipped when the banner is turned off, and always when the log
/// format is JSON: stdout is a machine-readable stream then, and a banner would corrupt it.
pub fn print(cfg: &LoggingConfig) {
if !cfg.banner || cfg.format == LogFormat::Json {
return;
}

let (accent, bold, dim, reset) = if cfg.color {
(ACCENT, BOLD, DIM, RESET)
} else {
("", "", "", "")
};

let cores = std::thread::available_parallelism()
.map(|n| n.get().to_string())
.unwrap_or_else(|_| "?".to_string());
let facts = format!(
"v{} · {} cores · {}/{}",
env!("CARGO_PKG_VERSION"),
cores,
std::env::consts::OS,
std::env::consts::ARCH,
);

println!("\n{accent}{bold}{LOGO}{reset}\n");
println!(" {dim}{TAGLINE}{reset}");
println!(" {dim}{facts}{reset}\n");
}
5 changes: 4 additions & 1 deletion src/utils/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
//! Logging, installed once at startup.
//! Startup odds and ends: logging, the console banner, and the update check.

pub(crate) mod ansi;
pub mod banner;
mod logging;
pub mod update;

pub use logging::init;
129 changes: 129 additions & 0 deletions src/utils/update.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! A one-shot check against the GitHub releases API, announcing an upgrade when one is newer.

use std::time::Duration;

use semver::Version;
use serde::Deserialize;

use crate::config::{LogFormat, LoggingConfig};
use crate::utils::ansi::{ACCENT, BOLD, DIM, RESET};

const RELEASES_API: &str = "https://api.github.com/repos/bongo-devs/Kairo/releases/latest";
const IMAGE: &str = "ghcr.io/bongo-devs/kairo:latest";
const TIMEOUT: Duration = Duration::from_secs(10);

#[derive(Deserialize)]
struct Release {
tag_name: String,
}

/// Ask GitHub for the latest release once and, if its tag outranks the running build, announce how
/// to upgrade. Set `KAIRO_NO_UPDATE_CHECK` to skip it entirely.
///
/// Every failure is logged at debug and otherwise dropped: a version check has no business keeping
/// the node from serving, and a proxied or air-gapped host is expected to fail here. The default
/// client honours `HTTPS_PROXY`, so a proxied deployment still reaches GitHub without extra config.
pub async fn check(logging: &LoggingConfig) {
if std::env::var_os("KAIRO_NO_UPDATE_CHECK").is_some() {
return;
}

let Ok(current) = Version::parse(env!("CARGO_PKG_VERSION")) else {
return;
};

let release = match latest().await {
Ok(Some(release)) => release,
Ok(None) => return,
Err(err) => {
tracing::debug!("update check failed: {err}");
return;
}
};

match upgrade_to(&current, &release.tag_name) {
Some(latest) => announce(logging, &current, &latest),
None => tracing::debug!("Kairo is up to date (v{current})"),
}
}

// Draw the upgrade notice as a whitespace-isolated callout with the banner's accent gutter, so it
// carries the same look and reads apart from the log stream around it. Under JSON logging stdout
// must stay machine-readable, so there it is a structured warning instead.
fn announce(logging: &LoggingConfig, current: &Version, latest: &Version) {
if logging.format == LogFormat::Json {
tracing::warn!(%current, %latest, "a new Kairo release is available");
return;
}

let (accent, bold, dim, reset) = if logging.color {
(ACCENT, BOLD, DIM, RESET)
} else {
("", "", "", "")
};

println!(
"\n {accent}▍{reset} {bold}Kairo v{latest} is available{reset} {dim}· running v{current}{reset}\
\n {accent}▍{reset} {dim}docker pull {IMAGE} · then restart the container{reset}\n"
);
}

// The release tag outranks `current`, if it parses and is strictly newer. Tags carry an optional
// leading `v` that is not part of the semver.
fn upgrade_to(current: &Version, tag: &str) -> Option<Version> {
Version::parse(tag.trim_start_matches('v'))
.ok()
.filter(|latest| latest > current)
}

async fn latest() -> reqwest::Result<Option<Release>> {
let client = reqwest::Client::builder()
.user_agent(concat!("kairo/", env!("CARGO_PKG_VERSION")))
.timeout(TIMEOUT)
.build()?;

let response = client
.get(RELEASES_API)
.header("Accept", "application/vnd.github+json")
.send()
.await?;

// No published releases answers 404; nothing to compare against, and not worth a log line.
if !response.status().is_success() {
return Ok(None);
}

Ok(Some(response.json::<Release>().await?))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn recognises_a_newer_tag() {
let current = Version::parse("4.6.0").unwrap();
assert_eq!(
upgrade_to(&current, "v4.7.0"),
Some(Version::parse("4.7.0").unwrap())
);
assert_eq!(
upgrade_to(&current, "5.0.0"),
Some(Version::parse("5.0.0").unwrap())
);
}

#[test]
fn ignores_same_or_older_tags() {
let current = Version::parse("4.6.0").unwrap();
assert_eq!(upgrade_to(&current, "v4.6.0"), None);
assert_eq!(upgrade_to(&current, "4.5.9"), None);
}

#[test]
fn ignores_unparsable_tags() {
let current = Version::parse("4.6.0").unwrap();
assert_eq!(upgrade_to(&current, "nightly"), None);
assert_eq!(upgrade_to(&current, ""), None);
}
}
Loading