Skip to main content

soup_sdk/
client.rs

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
18/// (is_live_detail, live_detail)
19type LiveDetailState = (bool, Option<LiveDetail>);
20
21impl SoopHttpClient {
22    pub fn new() -> Self {
23        Self {
24            client: Client::new(),
25        }
26    }
27
28    /// 스트리머 ID로 방송 상세 정보를 가져옵니다.
29    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        // bytes를 공유해서 두 번의 파싱을 수행하되, bytes 복사는 피합니다
35        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        // 방송 중인 경우에만 전체 JSON을 파싱합니다
43        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            )) // URL 쿼리 파라미터 추가
67            .header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)"); // User-Agent 헤더 설정
68
69        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        // x-www-form-urlencoded 형식의 본문을 만듭니다.
87        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") // 헤더 설정
93            .header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)") // User-Agent 헤더 설정
94            .form(&params); // form-urlencoded 본문 추가
95
96        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    /// 스트리머 ID로 방송 상세 정보 response를 가져옵니다.
108    async fn fetch_live_detail_response(&self, streamer_id: &str) -> Result<Response> {
109        // x-www-form-urlencoded 형식의 본문을 만듭니다.
110        let params = [("bid", streamer_id)];
111
112        let request = self
113            .client
114            .post(PLAYER_LIVE_API_URL)
115            .query(&[("bjid", streamer_id)]) // URL 쿼리 파라미터 추가
116            .header("Content-Type", "application/x-www-form-urlencoded") // 헤더 설정
117            .header("User-Agent", "Mozilla/5.0 (compatible; SoopClient/1.0)") // User-Agent 헤더 설정
118            .form(&params); // form-urlencoded 본문 추가
119
120        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(&params);
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; // 마이크로초를 초로 변환
195        let mut all_events = Vec::new();
196        // ! 파일 시작점과 chunk size 계산 필요
197        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?; // 5분 간격
231            all_events.append(&mut file_events);
232        }
233
234        println!("총 {}개 이벤트 수집 완료", all_events.len());
235        Ok(all_events)
236    }
237}