1use crate::chat::events::Event;
2use crate::constants::{EMOTICON_API_URL, PLAYER_LIVE_API_URL};
3use crate::error::{Error, Result};
4use crate::models::{
5 LiveDetail, LiveDetailToCheck, RawLiveDetail, RawStation, RawVODDetailResponse, RawVODResponse,
6 SignatureEmoticonData, SignatureEmoticonResponse, Station, VOD, VODDetail, VODFile,
7 parse_soop_timestamp,
8};
9use crate::vod_chat_parser::parse_vod_chat_xml_with_start_time;
10use futures_util::TryFutureExt;
11use reqwest::{Client, Response};
12
13#[derive(Debug)]
14pub struct SoopHttpClient {
15 client: Client,
16}
17
18type LiveDetailState = (bool, Option<LiveDetail>);
20
21impl SoopHttpClient {
22 pub fn new() -> Self {
23 Self {
24 client: Client::new(),
25 }
26 }
27
28 pub async fn get_live_detail_state(&self, streamer_id: &str) -> Result<LiveDetailState> {
30 let resp = self.fetch_live_detail_response(streamer_id).await?;
31
32 let bytes = resp.bytes().await.map_err(|e| Error::ResponseJson(e))?;
33
34 let live_detail_to_check =
36 serde_json::from_slice::<LiveDetailToCheck>(&bytes).map_err(|e| Error::SerdeJson(e))?;
37
38 if !live_detail_to_check.is_streaming() {
39 return Ok((false, None));
40 }
41
42 let live_detail =
44 serde_json::from_slice::<RawLiveDetail>(&bytes).map_err(|e| Error::SerdeJson(e))?;
45
46 return Ok((
47 true,
48 Some(LiveDetail {
49 is_live: live_detail_to_check.is_streaming(),
50 ch_domain: live_detail.channel.ch_domain,
51 ch_pt: live_detail.channel.ch_pt,
52 ch_no: live_detail.channel.chat_no,
53 streamer_nick: live_detail.channel.bj_nick,
54 title: live_detail.channel.title,
55 categories: live_detail.channel.categories,
56 }),
57 ));
58 }
59
60 pub async fn get_station(&self, streamer_id: &str) -> Result<Station> {
61 let request = self
62 .client
63 .get(format!(
64 "https://chapi.sooplive.co.kr/api/{}/station",
65 streamer_id
66 )) .header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)"); let response = request.send().await?;
70
71 if !response.status().is_success() {
72 return Err(Error::Request(response.error_for_status().unwrap_err()));
73 }
74
75 let raw = response.json::<RawStation>().await?;
76
77 return Ok(Station {
78 broad_start: parse_soop_timestamp(&raw.station.broad_start),
79 is_password: raw.broad.is_password,
80 viewer_count: raw.broad.viewer_count,
81 title: raw.broad.title,
82 });
83 }
84
85 pub async fn get_signature_emoticon(&self, streamer_id: &str) -> Result<SignatureEmoticonData> {
86 let params = [("szBjId", streamer_id), ("work", "list"), ("v", "tier")];
88
89 let request = self
90 .client
91 .post(EMOTICON_API_URL)
92 .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?;
97
98 if !response.status().is_success() {
99 return Err(Error::Request(response.error_for_status().unwrap_err()));
100 }
101
102 let emoticon_response = response.json::<SignatureEmoticonResponse>().await?;
103
104 return Ok(emoticon_response.data);
105 }
106
107 async fn fetch_live_detail_response(&self, streamer_id: &str) -> Result<Response> {
109 let params = [("bid", streamer_id)];
111
112 let request = self
113 .client
114 .post(PLAYER_LIVE_API_URL)
115 .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?;
121
122 if !response.status().is_success() {
123 return Err(Error::Request(response.error_for_status().unwrap_err()));
124 }
125
126 Ok(response)
127 }
128
129 pub async fn get_vod_list(&self, streamer_id: &str, page: u32) -> Result<Vec<VOD>> {
130 let url = format!(
131 "https://chapi.sooplive.co.kr/api/{}/vods/review?page={}&per_page=60&orderby=reg_date&field=title%2Ccontents&created=false",
132 streamer_id, page
133 );
134
135 let request = self
136 .client
137 .get(&url)
138 .header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)");
139
140 let response = request.send().await?;
141
142 if !response.status().is_success() {
143 return Err(Error::Request(response.error_for_status().unwrap_err()));
144 }
145
146 let vod_response = response.json::<RawVODResponse>().await?;
147 Ok(vod_response.into_vods())
148 }
149
150 pub async fn get_vod_detail(&self, vod_id: u64) -> Result<VODDetail> {
151 let params = [("nTitleNo", vod_id.to_string())];
152
153 let request = self
154 .client
155 .post("https://api.m.sooplive.co.kr/station/video/a/view")
156 .header("Content-Type", "application/x-www-form-urlencoded")
157 .header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)")
158 .form(¶ms);
159
160 let response = request.send().await?;
161
162 if !response.status().is_success() {
163 return Err(Error::Request(response.error_for_status().unwrap_err()));
164 }
165
166 let vod_detail_response = response.json::<RawVODDetailResponse>().await?;
167 vod_detail_response.into_vod_detail()
168 }
169
170 pub async fn get_vod_chat(&self, chat_url: &str, start_time: u64) -> Result<String> {
171 let url = format!("{}&startTime={}", chat_url, start_time);
172
173 let request = self
174 .client
175 .get(&url)
176 .header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)");
177
178 let response = request.send().await?;
179
180 if !response.status().is_success() {
181 return Err(Error::Request(response.error_for_status().unwrap_err()));
182 }
183
184 let xml_content = response.text().await?;
185 Ok(xml_content)
186 }
187
188 async fn get_file_chat_events(
189 &self,
190 file: &VODFile,
191 broad_start: &str,
192 chunk_size_seconds: u64,
193 ) -> Result<Vec<Event>> {
194 let duration_seconds = file.duration / 1_000_000; let mut all_events = Vec::new();
196 let mut current_time = 0;
198
199 while current_time < duration_seconds {
200 match self.get_vod_chat(&file.chat, current_time).await {
201 Ok(xml_content) => {
202 if !xml_content.trim().is_empty() {
203 match parse_vod_chat_xml_with_start_time(&xml_content, Some(broad_start)) {
204 Ok(mut events) => {
205 all_events.append(&mut events);
206 }
207 Err(e) => {
208 eprintln!("XML 파싱 오류 (시간 {}초): {}", current_time, e);
209 }
210 }
211 }
212 }
213 Err(e) => {
214 eprintln!("채팅 조회 오류 (시간 {}초): {}", current_time, e);
215 }
216 }
217 current_time += chunk_size_seconds;
218 }
219
220 Ok(all_events)
221 }
222
223 pub async fn get_full_vod_chat(&self, vod_id: u64) -> Result<Vec<Event>> {
224 let vod_detail = self.get_vod_detail(vod_id).await?;
225 let mut all_events = Vec::new();
226
227 for (_, file) in vod_detail.files.iter().enumerate() {
228 let mut file_events = self
229 .get_file_chat_events(file, &vod_detail.broad_start, 300)
230 .await?; all_events.append(&mut file_events);
232 }
233
234 println!("총 {}개 이벤트 수집 완료", all_events.len());
235 Ok(all_events)
236 }
237}