use std::env;
use std::sync::Arc;
use std::time::Duration;
use soup_sdk::{
LiveStatus, SoopHttpClient,
chat::{Event, SoopChatConnection, SoopChatOptions, commands::Command},
};
const DEFAULT_CHAT_SAMPLE_SECONDS: u64 = 20;
const MAX_PRINTED_EVENT_SAMPLES: usize = 10;
const MAX_PRINTED_PARTICIPANTS: usize = 10;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = DebugConfig::from_args(env::args())?;
let client = Arc::new(SoopHttpClient::new());
println!("SOOP SDK debug target: {}", config.streamer_id);
println!("chat sample seconds: {}", config.chat_sample_seconds);
println!();
let live_status = check_http_apis(Arc::clone(&client), &config.streamer_id).await?;
check_live_status(&live_status);
if matches!(live_status, LiveStatus::Offline) {
println!("[skip] live-chat: streamer is offline");
return Ok(());
}
check_live_chat(
Arc::clone(&client),
&config.streamer_id,
config.chat_sample_seconds,
&config.participant_labels,
)
.await?;
Ok(())
}
struct DebugConfig {
streamer_id: String,
chat_sample_seconds: u64,
participant_labels: Vec<String>,
}
impl DebugConfig {
fn from_args(args: impl IntoIterator<Item = String>) -> anyhow::Result<Self> {
let mut args = args.into_iter();
let program = args.next().unwrap_or_else(|| "soup-sdk".to_string());
let Some(streamer_id) = args.next() else {
anyhow::bail!(
"missing streamer id\n\nusage: {program} <streamer_id> [chat_sample_seconds] [participant_labels]\nexample: {program} danchu17 20 나른자,모지나"
);
};
let chat_sample_seconds = match args.next() {
Some(value) => value.parse::<u64>().map_err(|error| {
anyhow::anyhow!("invalid chat_sample_seconds value {value:?}: {error}")
})?,
None => DEFAULT_CHAT_SAMPLE_SECONDS,
};
let participant_labels = args
.next()
.map(|value| {
value
.split(',')
.map(str::trim)
.filter(|label| !label.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if args.next().is_some() {
anyhow::bail!(
"too many arguments\n\nusage: {program} <streamer_id> [chat_sample_seconds] [participant_labels]"
);
}
if streamer_id.trim().is_empty() {
anyhow::bail!("streamer id must not be empty");
}
if chat_sample_seconds == 0 {
anyhow::bail!("chat_sample_seconds must be greater than zero");
}
Ok(Self {
streamer_id,
chat_sample_seconds,
participant_labels,
})
}
}
async fn check_http_apis(
client: Arc<SoopHttpClient>,
streamer_id: &str,
) -> anyhow::Result<LiveStatus> {
println!("== API calls ==");
let live_status = client.get_live_status(streamer_id).await?;
match &live_status {
LiveStatus::Live(detail) => {
println!(
"[ok] get_live_status: live nick={} title={} ch_domain={} ch_pt={} ch_no={} categories={:?}",
detail.streamer_nick,
detail.title,
detail.ch_domain,
detail.ch_pt,
detail.ch_no,
detail.categories
);
}
LiveStatus::Offline => {
println!("[ok] get_live_status: offline");
}
}
let station = client.get_station(streamer_id).await?;
println!(
"[ok] get_station: title={} viewer_count={} is_password={} broad_start={}",
station.title, station.viewer_count, station.is_password, station.broad_start
);
let emoticons = client.get_signature_emoticon(streamer_id).await?;
println!(
"[ok] get_signature_emoticon: tier1={} tier2={}",
emoticons.tier_1.len(),
emoticons.tier_2.len()
);
match client.get_vod_list(streamer_id, 1).await {
Ok(vods) => {
println!("[ok] get_vod_list: public_vods={}", vods.len());
if let Some(first_vod) = vods.first() {
println!(
"[ok] get_vod_list first: id={} title={} duration={} thumbnail={} reg_date={}",
first_vod.id,
first_vod.title,
first_vod.duration,
first_vod.thumbnail_url,
first_vod.reg_date
);
}
}
Err(error) => println!("[warn] get_vod_list: {error}"),
}
println!();
Ok(live_status)
}
fn check_live_status(live_status: &LiveStatus) {
println!("== Live status ==");
match live_status {
LiveStatus::Live(detail) => {
println!(
"[ok] live: streamer_nick={} title={} chat={}:{} room={}",
detail.streamer_nick, detail.title, detail.ch_domain, detail.ch_pt, detail.ch_no
);
}
LiveStatus::Offline => {
println!("[ok] offline");
}
}
println!();
}
async fn check_live_chat(
client: Arc<SoopHttpClient>,
streamer_id: &str,
sample_seconds: u64,
participant_labels: &[String],
) -> anyhow::Result<()> {
println!("== Live chat ==");
let options = SoopChatOptions {
streamer_id: streamer_id.to_string(),
password: String::new(),
diagnostics: false,
};
let connection = SoopChatConnection::new(client, options)?;
let mut events = connection.subscribe();
connection.start().await?;
println!("[ok] start: connected task started, sampling {sample_seconds}s");
tokio::time::sleep(Duration::from_secs(1)).await;
connection.command(Command::RequestParticipantList)?;
println!("[ok] participant-list: request sent");
let deadline = tokio::time::Instant::now() + Duration::from_secs(sample_seconds);
let mut counts = ChatEventCounts::default();
let mut printed_samples = 0usize;
while tokio::time::Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(remaining, events.recv()).await {
Ok(Ok(event)) => {
count_event(&mut counts, &event);
if let Event::Unknown(code) = &event {
println!("[event] unknown code={code:?}");
}
if printed_samples < MAX_PRINTED_EVENT_SAMPLES && should_print_event_sample(&event)
{
print_event_sample(&event, participant_labels);
printed_samples += 1;
}
}
Ok(Err(error)) => {
println!("[warn] recv: {error}");
break;
}
Err(_) => break,
}
}
connection.command(Command::Shutdown)?;
if let Ok(Ok(event)) = tokio::time::timeout(Duration::from_secs(2), events.recv()).await {
count_event(&mut counts, &event);
if should_print_event_sample(&event) {
print_event_sample(&event, participant_labels);
}
}
println!(
"[ok] summary: connected={} disconnected={} chat={} donation={} subscribe={} participant_lists={} participant_count={} diagnostic={} raw={} unknown={} other={}",
counts.connected,
counts.disconnected,
counts.chat,
counts.donation,
counts.subscribe,
counts.participant_lists,
counts.participant_count,
counts.diagnostic,
counts.raw,
counts.unknown,
counts.other
);
Ok(())
}
#[derive(Default)]
struct ChatEventCounts {
connected: usize,
disconnected: usize,
chat: usize,
donation: usize,
subscribe: usize,
participant_lists: usize,
participant_count: usize,
diagnostic: usize,
raw: usize,
unknown: usize,
other: usize,
}
fn count_event(counts: &mut ChatEventCounts, event: &Event) {
match event {
Event::Connected => counts.connected += 1,
Event::Disconnected(_) => counts.disconnected += 1,
Event::Chat(_) => counts.chat += 1,
Event::Donation(_) => counts.donation += 1,
Event::Subscribe(_) => counts.subscribe += 1,
Event::Enter(_) => {}
Event::ParticipantList(event) => {
counts.participant_lists += 1;
counts.participant_count = event.count;
}
Event::Diagnostic(_) => counts.diagnostic += 1,
Event::Raw(_) => counts.raw += 1,
Event::Unknown(_) => counts.unknown += 1,
_ => counts.other += 1,
}
}
fn print_event_sample(event: &Event, participant_labels: &[String]) {
match event {
Event::Connected => println!("[event] connected"),
Event::Disconnected(event) => println!("[event] disconnected reason={:?}", event.reason),
Event::Chat(event) => println!(
"[event] chat user={} comment={}",
event.user.label, event.comment
),
Event::Donation(event) => println!(
"[event] donation from={} amount={}",
event.from_label, event.amount
),
Event::Subscribe(event) => println!("[event] subscribe {event:?}"),
Event::Enter(event) => println!("[event] enter user={}", event.user.label),
Event::ParticipantList(event) => {
println!(
"[event] participant-list count={} result_code={}",
event.count, event.result_code
);
for participant in event.entries.iter().take(MAX_PRINTED_PARTICIPANTS) {
println!(
"[event] participant user={} id={} follow_level={} fan_level={} supporter={} manager={}",
participant.label,
participant.id,
participant.status.follow_level,
participant.status.fan_level,
participant.status.is_supporter,
participant.status.is_manager
);
}
let remaining = event.entries.len().saturating_sub(MAX_PRINTED_PARTICIPANTS);
if remaining > 0 {
println!("[event] participant-list ... and {remaining} more");
}
for label in participant_labels {
match event
.entries
.iter()
.find(|participant| &participant.label == label)
{
Some(participant) => println!(
"[event] participant-match label={} id={} follow_level={} fan_level={} supporter={} manager={}",
participant.label,
participant.id,
participant.status.follow_level,
participant.status.fan_level,
participant.status.is_supporter,
participant.status.is_manager
),
None => println!("[event] participant-match label={label} not found"),
}
}
}
Event::Diagnostic(event) => println!("[event] diagnostic {event:?}"),
Event::Raw(raw) => println!("[event] raw bytes={}", raw.len()),
Event::Unknown(code) => println!("[event] unknown code={code:?}"),
other => println!("[event] other {other:?}"),
}
}
fn should_print_event_sample(event: &Event) -> bool {
!matches!(
event,
Event::Diagnostic(_) | Event::Raw(_) | Event::Unknown(_)
)
}