use std::time::{Duration, Instant};
use heatsync_core::Platform;
use crate::config;
use crate::http;
use crate::net::{self, ChatEvent};
fn split_channel(tok: &str) -> (Platform, &str) {
if let Some(rest) = tok.strip_prefix("kick:") {
(Platform::Kick, rest)
} else if let Some(rest) = tok.strip_prefix("twitch:") {
(Platform::Twitch, rest)
} else {
(Platform::Twitch, tok)
}
}
pub fn log(args: &[String]) -> std::io::Result<()> {
let (channel, date) = match args {
[c, d, ..] => (c.as_str(), d.as_str()),
_ => {
eprintln!("usage: heatsync log <channel> <YYYY-MM-DD>");
std::process::exit(2);
}
};
let (_plat, name) = split_channel(channel);
let from = format!("{date}T00:00:00Z");
let to = format!("{date}T23:59:59Z");
match http::channel_log(name, &from, &to, 100) {
Some(page) if !page.results.is_empty() => {
for m in &page.results {
let who = m.display_name.as_deref().unwrap_or(&m.username);
println!("{} {}: {}", short_ts(&m.timestamp), who, m.message);
}
}
Some(_) => eprintln!("no logs for {name} on {date}"),
None => {
eprintln!("request failed");
std::process::exit(1);
}
}
Ok(())
}
pub fn search(args: &[String]) -> std::io::Result<()> {
let q = match args.first() {
Some(q) => q.as_str(),
None => {
eprintln!("usage: heatsync search <query> [channel]");
std::process::exit(2);
}
};
let channel = args.get(1).map(|s| split_channel(s).1);
match http::search(q, channel, 50) {
Some(page) if !page.results.is_empty() => {
for m in &page.results {
let who = m.display_name.as_deref().unwrap_or(&m.username);
println!(
"{} [{}/{}] {}: {}",
short_ts(&m.timestamp),
m.platform,
m.channel,
who,
m.message
);
}
}
Some(_) => eprintln!("no matches for {q:?}"),
None => {
eprintln!("request failed");
std::process::exit(1);
}
}
Ok(())
}
pub fn hot(args: &[String]) -> std::io::Result<()> {
let hours: u32 = args.first().and_then(|s| s.parse().ok()).unwrap_or(720);
match http::hot(25, hours) {
Some(page) if !page.messages.is_empty() => {
for m in &page.messages {
let who = m.display_name.as_deref().unwrap_or(&m.username);
let subj = m.subject.as_deref().unwrap_or("");
println!(
"{:>6.0} (peak {:>6.0}) {}: {} {}",
m.heat, m.max_heat, who, m.content, subj
);
}
}
Some(_) => eprintln!("nothing hot in the last {hours}h"),
None => {
eprintln!("request failed");
std::process::exit(1);
}
}
Ok(())
}
pub fn login() -> std::io::Result<()> {
match config::ensure_token_file() {
Some(p) => {
println!(
"twitch sending — sends go DIRECT to twitch (like chatterino), not via heatsync:"
);
println!(" 1. get a twitch oauth token with the 'chat:edit' scope");
println!(" e.g. https://twitchtokengenerator.com (pick a bot/chat token)");
println!(" 2. edit {}", p.display());
println!(" twitch_user=your_twitch_username");
println!(" twitch_oauth=oauth:xxxxxxxxxxxxxxxx");
println!(" 3. restart heatsync. reading still comes through heatsync; only sending is direct.");
println!(" (or set TWITCH_USER / TWITCH_OAUTH env instead of the file.)");
}
None => eprintln!("could not resolve ~/.config/heatsync/"),
}
Ok(())
}
pub fn status() -> std::io::Result<()> {
let auth = config::load_auth();
let Some(token) = auth.admin_token else {
eprintln!("no admin token — add `admin_token=<jwt>` to ~/.config/heatsync/token");
std::process::exit(1);
};
let mut ok = true;
let health = http::admin_health(&token);
let mod_queue = http::admin_mod_queue_total(&token);
let reports = http::admin_pending_reports(&token);
let ncmec = http::admin_ncmec_backlog(&token);
println!("heatsync — prod status");
match &health {
Some(h) => {
println!(
" postgres: {} redis: {}",
h.postgres_status, h.redis_status
);
println!(
" cpu {:>5.1}% ram {:>5.1}% disk {:>5.1}% errors/min {:.1}",
h.cpu_usage, h.ram_usage, h.disk_usage, h.error_rate
);
println!(
" ws conns: {} msgs/min: {:.0}",
h.ws_connections, h.messages_per_minute
);
}
None => {
println!(" health: unreachable (auth expired? check ~/.config/heatsync/token)");
ok = false;
}
}
match mod_queue {
Some(n) => println!(" mod queue: {n:>4} pending"),
None => {
println!(" mod queue: ? (request failed)");
ok = false;
}
}
match reports {
Some(n) => println!(" reports: {n:>4} pending"),
None => {
println!(" reports: ? (request failed)");
ok = false;
}
}
match ncmec {
Some(n) if n > 0 => println!(" ncmec: {n:>4} STUCK — needs a look"),
Some(_) => println!(" ncmec: 0 clear"),
None => {
println!(" ncmec: ? (request failed)");
ok = false;
}
}
if !ok {
std::process::exit(1);
}
Ok(())
}
pub fn probe(args: &[String]) -> std::io::Result<()> {
let subs = if args.is_empty() {
vec![(Platform::Twitch, "xqc".to_string())]
} else {
args.iter()
.map(|a| {
let (p, n) = split_channel(a);
(p, n.to_string())
})
.collect()
};
eprintln!("probing {} channel(s) for 8s…", subs.len());
let (rx, _out) = net::spawn(subs, None);
let deadline = Instant::now() + Duration::from_secs(8);
let mut n = 0u32;
while let Some(rem) = deadline.checked_duration_since(Instant::now()) {
match rx.recv_timeout(rem) {
Ok(ChatEvent::Line(l)) => {
println!(
"[{}/{}] {}: {}",
plat_tag(l.platform),
l.channel,
l.user,
l.content
);
n += 1;
}
Ok(ChatEvent::Connected) => eprintln!("· connected"),
Ok(ChatEvent::Disconnected) => eprintln!("· disconnected"),
Ok(_) => {}
Err(_) => break,
}
}
eprintln!("{n} lines in 8s");
Ok(())
}
fn plat_tag(p: Platform) -> &'static str {
p.tag()
}
fn short_ts(ts: &str) -> &str {
ts.split('T')
.nth(1)
.map(|t| &t[..t.len().min(8)])
.unwrap_or(ts)
}
pub fn render_test(args: &[String]) -> std::io::Result<()> {
use ratatui_image::picker::ProtocolType;
if args.is_empty() {
eprintln!("usage: heatsync render-test <base-url> [overlay-url …]");
return Ok(());
}
let key = args.join("\n");
let Some(frames) = crate::emote::render::composite_frames(&key, 32) else {
eprintln!("render-test: composite failed (fetch/decode)");
return Ok(());
};
let (w, h) = (frames[0].0.width(), frames[0].0.height());
eprintln!(
"render-test: {} layer(s) → {} composited frame(s), {w}x{h}px",
args.len(),
frames.len()
);
if frames.len() > 1 {
let total: u32 = frames.iter().map(|f| f.1.max(20)).sum();
let fps = frames.len() as f64 * 1000.0 / total.max(1) as f64;
eprintln!(
" authored: {:.0}ms loop at {fps:.1}fps (a 100ms redraw cadence would \
show {:.0} of these {} frames)",
total,
(total as f64 / 100.0).floor(),
frames.len()
);
}
let idxs = if frames.len() > 1 {
vec![0usize, frames.len() / 2]
} else {
vec![0usize]
};
for i in &idxs {
let p = format!("/tmp/emote_composite_{i}.png");
if frames[*i].0.save(&p).is_ok() {
eprintln!(" composite frame {i} → {p}");
}
}
let (cw, ch) = (8u16, 16u16);
let mut picker = ratatui_image::picker::Picker::from_fontsize((cw, ch));
picker.set_protocol_type(ProtocolType::Sixel);
let block_h = crate::emote::render::EMOTE_H as u32 * ch as u32;
let w_cells = crate::emote::render::width_cells(w as f32 / h as f32, cw, ch);
eprintln!(
" footprint on an {cw}x{ch} cell: {w_cells}x{} cells = {}x{block_h}px (source {w}x{h})",
crate::emote::render::EMOTE_H,
w_cells as u32 * cw as u32,
);
let take = frames.len().min(24);
let mut canvases: Vec<image::RgbaImage> = frames[..take]
.iter()
.map(|(f, _)| crate::emote::render::fit_center(f, w_cells as u32 * cw as u32, block_h))
.collect();
for c in &mut canvases {
crate::emote::render::flatten_onto_black(c);
}
let raw = encode_all(&picker, &canvases, w_cells);
crate::emote::render::stabilize_palette(&mut canvases);
let stable = encode_all(&picker, &canvases, w_cells);
if let Some(first) = stable.first() {
std::fs::write("/tmp/emote.six", first)?;
let wrapped = first.contains("tmux;");
eprintln!(
" sixel bytes → /tmp/emote.six ({} bytes){}",
first.len(),
if wrapped {
" [tmux-wrapped — rerun with `env -u TMUX`]"
} else {
""
}
);
}
if take > 1 {
let px = (w_cells as u32 * cw as u32, block_h);
eprintln!(" pixels changing between consecutive frames (lower = less shimmer):");
eprintln!(
" per-frame palette (before): {:.1}%",
pixel_churn(&raw, px) * 100.0
);
eprintln!(
" shared palette (after): {:.1}%",
pixel_churn(&stable, px) * 100.0
);
eprintln!(
" source frames actually differ by: {:.1}% (the real motion)",
source_churn(&canvases_src(
&frames[..take],
w_cells as u32 * cw as u32,
block_h
)) * 100.0
);
}
Ok(())
}
fn canvases_src(frames: &[(image::RgbaImage, u32)], tw: u32, th: u32) -> Vec<image::RgbaImage> {
frames
.iter()
.map(|(f, _)| {
let mut c = crate::emote::render::fit_center(f, tw, th);
crate::emote::render::flatten_onto_black(&mut c);
c
})
.collect()
}
fn source_churn(frames: &[image::RgbaImage]) -> f64 {
if frames.len() < 2 {
return 0.0;
}
let mut total = 0.0;
for w in frames.windows(2) {
let n = w[0].pixels().len().max(1);
let diff = w[0]
.pixels()
.zip(w[1].pixels())
.filter(|(a, b)| a != b)
.count();
total += diff as f64 / n as f64;
}
total / (frames.len() - 1) as f64
}
fn pixel_churn(frames: &[String], (w, h): (u32, u32)) -> f64 {
let decoded: Vec<Vec<u32>> = frames.iter().map(|s| sixel_decode(s, w, h)).collect();
if decoded.len() < 2 {
return 0.0;
}
let mut total = 0.0;
for pair in decoded.windows(2) {
let n = (w * h) as usize;
let diff = pair[0].iter().zip(&pair[1]).filter(|(a, b)| a != b).count();
total += diff as f64 / n.max(1) as f64;
}
total / (decoded.len() - 1) as f64
}
fn sixel_decode(data: &str, w: u32, h: u32) -> Vec<u32> {
let mut out = vec![0u32; (w * h) as usize];
let mut palette = std::collections::HashMap::new();
let b: Vec<char> = data.chars().collect();
let mut i = 0usize;
while i < b.len() && b[i] != 'q' {
i += 1;
}
i += 1;
let (mut x, mut band, mut color) = (0u32, 0u32, 0u32);
let num = |b: &[char], i: &mut usize| -> u32 {
let mut n = 0u32;
while *i < b.len() && b[*i].is_ascii_digit() {
n = n
.saturating_mul(10)
.saturating_add(b[*i] as u32 - '0' as u32);
*i += 1;
}
n
};
while i < b.len() {
match b[i] {
'#' => {
i += 1;
let idx = num(&b, &mut i);
if i < b.len() && b[i] == ';' {
i += 1;
let _mode = num(&b, &mut i);
let mut c = [0u32; 3];
for slot in c.iter_mut() {
if i < b.len() && b[i] == ';' {
i += 1;
}
*slot = num(&b, &mut i);
}
palette.insert(idx, (c[0] << 16) | (c[1] << 8) | c[2] | 1 << 24);
}
color = idx;
}
'!' => {
i += 1;
let n = num(&b, &mut i);
if i < b.len() {
let ch = b[i];
i += 1;
for _ in 0..n {
put(
&mut out,
w,
h,
x,
band,
ch,
*palette.get(&color).unwrap_or(&0),
);
x += 1;
}
}
}
'$' => {
x = 0;
i += 1;
}
'-' => {
x = 0;
band += 1;
i += 1;
}
'"' => {
i += 1;
while i < b.len() && (b[i].is_ascii_digit() || b[i] == ';') {
i += 1;
}
}
c if ('?'..='~').contains(&c) => {
put(
&mut out,
w,
h,
x,
band,
c,
*palette.get(&color).unwrap_or(&0),
);
x += 1;
i += 1;
}
_ => i += 1,
}
}
out
}
fn put(out: &mut [u32], w: u32, h: u32, x: u32, band: u32, ch: char, rgb: u32) {
if x >= w {
return;
}
let bits = (ch as u32).wrapping_sub('?' as u32);
for bit in 0..6u32 {
if bits & (1 << bit) != 0 {
let y = band * 6 + bit;
if y < h {
out[(y * w + x) as usize] = rgb;
}
}
}
}
fn encode_all(
picker: &ratatui_image::picker::Picker,
canvases: &[image::RgbaImage],
w_cells: u16,
) -> Vec<String> {
let size = ratatui::layout::Rect::new(0, 0, w_cells, crate::emote::render::EMOTE_H);
canvases
.iter()
.filter_map(|c| {
match picker.new_protocol(
image::DynamicImage::ImageRgba8(c.clone()),
size,
ratatui_image::Resize::Fit(None),
) {
Ok(ratatui_image::protocol::Protocol::Sixel(s)) => Some(s.data),
_ => None,
}
})
.collect()
}
pub fn diag(_args: &[String]) -> std::io::Result<()> {
let env = |k: &str| std::env::var(k).unwrap_or_else(|_| "<unset>".into());
println!(
"TERM={} TMUX={} WAYLAND_DISPLAY={} DISPLAY={}",
env("TERM"),
if std::env::var_os("TMUX").is_some() {
"set"
} else {
"<unset>"
},
env("WAYLAND_DISPLAY"),
env("DISPLAY")
);
let (cols, rows) = match crossterm::terminal::size() {
Ok((c, r)) => {
println!("crossterm size = {c}x{r} cells");
(c, r)
}
Err(e) => {
println!("crossterm size = ERR {e}");
(0, 0)
}
};
println!("glyphs (blank = font can't map it): '\u{276f}' '\u{2588}' '\u{25cf}' '\u{25cb}' '\u{2500}' '\u{2502}' '\u{250c}'");
println!("--- tier decision ---");
let mut reasons: Vec<String> = Vec::new();
if std::env::var_os("TMUX").is_some() {
let client = std::process::Command::new("tmux")
.args(["display", "-p", "#{client_termname}"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
println!("tmux client_termname = {client:?}");
if client != "linux" {
reasons.push(format!("tmux client is {client:?}, not a kernel console"));
}
} else {
if std::env::var("TERM").ok().as_deref() != Some("linux") {
reasons.push("TERM != linux and not in tmux".into());
}
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
reasons.push("WAYLAND_DISPLAY set (fb0 is not the scanned-out surface)".into());
}
if std::env::var_os("DISPLAY").is_some() {
reasons.push("DISPLAY set (fb0 is not the scanned-out surface)".into());
}
}
if cols == 0 || rows == 0 {
reasons.push("terminal size reported 0 — fb tier needs a real cell grid".into());
}
#[cfg(target_os = "linux")]
{
match framebuffer::Framebuffer::new("/dev/fb0") {
Err(e) => reasons.push(format!("/dev/fb0 open failed: {e:?}")),
Ok(fb) => {
let v = fb.var_screen_info.clone();
let f = fb.fix_screen_info.clone();
println!(
"fb0: {}x{} bpp={} line_length={} smem_len={} xoff={} yoff={}",
v.xres,
v.yres,
v.bits_per_pixel,
f.line_length,
f.smem_len,
v.xoffset,
v.yoffset
);
println!(
"fb0 bitfields: r=({},{}) g=({},{}) b=({},{})",
v.red.offset,
v.red.length,
v.green.offset,
v.green.length,
v.blue.offset,
v.blue.length
);
println!("fb0 mmap len = {} bytes", fb.frame.len());
if cols > 0 && rows > 0 {
println!(
"cell = {}x{} px",
(v.xres / cols as u32).max(1),
(v.yres / rows as u32).max(1)
);
}
if reasons.is_empty() {
paint_test(
fb,
v.xres,
v.yres,
f.line_length,
(v.bits_per_pixel / 8).max(1),
);
}
}
}
}
if reasons.is_empty() {
println!("=> framebuffer tier ACTIVE");
} else {
println!("=> framebuffer tier REJECTED:");
for r in &reasons {
println!(" - {r}");
}
println!(" (falls back to the terminal-graphics tier, else text)");
}
Ok(())
}
#[cfg(target_os = "linux")]
fn paint_test(mut fb: framebuffer::Framebuffer, xres: u32, yres: u32, line_len: u32, bpp: u32) {
let (w, h) = (200u32.min(xres), 100u32.min(yres));
let buf = &mut fb.frame;
for y in 100..(100 + h) {
for x in 100..(100 + w) {
let off = (y as usize) * line_len as usize + (x as usize) * bpp as usize;
if off + bpp as usize > buf.len() {
continue;
}
for (i, b) in [0x00u8, 0x87, 0xff, 0x00]
.iter()
.take(bpp as usize)
.enumerate()
{
buf[off + i] = *b;
}
}
}
println!("painted an ORANGE block at px(100,100) size 200x100 — holding 5s");
std::thread::sleep(std::time::Duration::from_secs(5));
}