use crate::chat::events::Event;
use crate::constants::{EMOTICON_API_URL, PLAYER_LIVE_API_URL};
use crate::error::{Error, Result};
use crate::models::{
LiveDetail, LiveDetailToCheck, RawLiveDetail, RawStation, RawVODDetailResponse, RawVODResponse,
SignatureEmoticonData, SignatureEmoticonResponse, Station, VOD, VODDetail, VODFile,
parse_soop_timestamp,
};
use crate::vod_chat_parser::parse_vod_chat_xml_with_start_time;
use futures_util::TryFutureExt;
use reqwest::{Client, Response};
#[derive(Debug)]
pub struct SoopHttpClient {
client: Client,
}
type LiveDetailState = (bool, Option<LiveDetail>);
impl SoopHttpClient {
pub fn new() -> Self {
Self {
client: Client::new(),
}
}
pub async fn get_live_detail_state(&self, streamer_id: &str) -> Result<LiveDetailState> {
let resp = self.fetch_live_detail_response(streamer_id).await?;
let bytes = resp.bytes().await.map_err(|e| Error::ResponseJson(e))?;
let live_detail_to_check =
serde_json::from_slice::<LiveDetailToCheck>(&bytes).map_err(|e| Error::SerdeJson(e))?;
if !live_detail_to_check.is_streaming() {
return Ok((false, None));
}
let live_detail =
serde_json::from_slice::<RawLiveDetail>(&bytes).map_err(|e| Error::SerdeJson(e))?;
return Ok((
true,
Some(LiveDetail {
is_live: live_detail_to_check.is_streaming(),
ch_domain: live_detail.channel.ch_domain,
ch_pt: live_detail.channel.ch_pt,
ch_no: live_detail.channel.chat_no,
streamer_nick: live_detail.channel.bj_nick,
title: live_detail.channel.title,
categories: live_detail.channel.categories,
}),
));
}
pub async fn get_station(&self, streamer_id: &str) -> Result<Station> {
let request = self
.client
.get(format!(
"https://chapi.sooplive.co.kr/api/{}/station",
streamer_id
)) .header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)");
let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::Request(response.error_for_status().unwrap_err()));
}
let raw = response.json::<RawStation>().await?;
return Ok(Station {
broad_start: parse_soop_timestamp(&raw.station.broad_start),
is_password: raw.broad.is_password,
viewer_count: raw.broad.viewer_count,
title: raw.broad.title,
});
}
pub async fn get_signature_emoticon(&self, streamer_id: &str) -> Result<SignatureEmoticonData> {
let params = [("szBjId", streamer_id), ("work", "list"), ("v", "tier")];
let request = self
.client
.post(EMOTICON_API_URL)
.header("Content-Type", "application/x-www-form-urlencoded") .header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)") .form(¶ms);
let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::Request(response.error_for_status().unwrap_err()));
}
let emoticon_response = response.json::<SignatureEmoticonResponse>().await?;
return Ok(emoticon_response.data);
}
async fn fetch_live_detail_response(&self, streamer_id: &str) -> Result<Response> {
let params = [("bid", streamer_id)];
let request = self
.client
.post(PLAYER_LIVE_API_URL)
.query(&[("bjid", streamer_id)]) .header("Content-Type", "application/x-www-form-urlencoded") .header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)") .form(¶ms);
let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::Request(response.error_for_status().unwrap_err()));
}
Ok(response)
}
pub async fn get_vod_list(&self, streamer_id: &str, page: u32) -> Result<Vec<VOD>> {
let url = format!(
"https://chapi.sooplive.co.kr/api/{}/vods/review?page={}&per_page=60&orderby=reg_date&field=title%2Ccontents&created=false",
streamer_id, page
);
let request = self
.client
.get(&url)
.header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)");
let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::Request(response.error_for_status().unwrap_err()));
}
let vod_response = response.json::<RawVODResponse>().await?;
Ok(vod_response.into_vods())
}
pub async fn get_vod_detail(&self, vod_id: u64) -> Result<VODDetail> {
let params = [("nTitleNo", vod_id.to_string())];
let request = self
.client
.post("https://api.m.sooplive.co.kr/station/video/a/view")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)")
.form(¶ms);
let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::Request(response.error_for_status().unwrap_err()));
}
let vod_detail_response = response.json::<RawVODDetailResponse>().await?;
vod_detail_response.into_vod_detail()
}
pub async fn get_vod_chat(&self, chat_url: &str, start_time: u64) -> Result<String> {
let url = format!("{}&startTime={}", chat_url, start_time);
let request = self
.client
.get(&url)
.header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)");
let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::Request(response.error_for_status().unwrap_err()));
}
let xml_content = response.text().await?;
Ok(xml_content)
}
async fn get_file_chat_events(
&self,
file: &VODFile,
broad_start: &str,
chunk_size_seconds: u64,
) -> Result<Vec<Event>> {
let duration_seconds = file.duration / 1_000_000; let mut all_events = Vec::new();
let mut current_time = 0;
while current_time < duration_seconds {
match self.get_vod_chat(&file.chat, current_time).await {
Ok(xml_content) => {
if !xml_content.trim().is_empty() {
match parse_vod_chat_xml_with_start_time(&xml_content, Some(broad_start)) {
Ok(mut events) => {
all_events.append(&mut events);
}
Err(e) => {
eprintln!("XML 파싱 오류 (시간 {}초): {}", current_time, e);
}
}
}
}
Err(e) => {
eprintln!("채팅 조회 오류 (시간 {}초): {}", current_time, e);
}
}
current_time += chunk_size_seconds;
}
Ok(all_events)
}
pub async fn get_full_vod_chat(&self, vod_id: u64) -> Result<Vec<Event>> {
let vod_detail = self.get_vod_detail(vod_id).await?;
let mut all_events = Vec::new();
for (_, file) in vod_detail.files.iter().enumerate() {
let mut file_events = self
.get_file_chat_events(file, &vod_detail.broad_start, 300)
.await?; all_events.append(&mut file_events);
}
println!("총 {}개 이벤트 수집 완료", all_events.len());
Ok(all_events)
}
}