soup-sdk 0.3.2

채팅 이벤트 수신 SDK
Documentation
use crate::api;
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, LiveStatus, RawLiveDetail, RawStation, RawVODDetailResponse,
    RawVODResponse, SignatureEmoticonData, SignatureEmoticonResponse, Station, VOD, VODDetail,
    VODFile, VodChatChunk, VodChatChunkFailure, VodChatCollection, parse_soop_timestamp,
};
use crate::vod_chat_parser::parse_vod_chat_xml_with_start_time;
use reqwest::{Client, Response};

#[derive(Debug)]
pub struct SoopHttpClient {
    client: Client,
}

impl SoopHttpClient {
    pub fn new() -> Self {
        Self {
            client: api::default_client(),
        }
    }

    /// 스트리머 ID로 방송 상세 정보를 가져옵니다.
    pub async fn get_live_status(&self, streamer_id: &str) -> Result<LiveStatus> {
        let resp = self.fetch_live_detail_response(streamer_id).await?;

        let bytes = resp.bytes().await.map_err(Error::Transport)?;

        // bytes를 공유해서 두 번의 파싱을 수행하되, bytes 복사는 피합니다
        let live_detail_to_check = serde_json::from_slice::<LiveDetailToCheck>(&bytes)
            .map_err(|e| Error::Decode(e.to_string()))?;

        if !live_detail_to_check.is_streaming() {
            return Ok(LiveStatus::Offline);
        }

        // 방송 중인 경우에만 전체 JSON을 파싱합니다
        let live_detail = serde_json::from_slice::<RawLiveDetail>(&bytes)
            .map_err(|e| Error::Decode(e.to_string()))?;

        Ok(LiveStatus::Live(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,
            bno: live_detail.channel.bno,
            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 = api::with_user_agent(self.client.get(format!(
            "https://chapi.sooplive.co.kr/api/{}/station",
            streamer_id
        )));

        let raw = api::send_json::<RawStation>(request).await?;

        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> {
        // x-www-form-urlencoded 형식의 본문을 만듭니다.
        let params = [("szBjId", streamer_id), ("work", "list"), ("v", "tier")];

        let request = api::with_form_headers(self.client.post(EMOTICON_API_URL)).form(&params);

        let emoticon_response = api::send_json::<SignatureEmoticonResponse>(request).await?;

        Ok(emoticon_response.data)
    }

    /// 스트리머 ID로 방송 상세 정보 response를 가져옵니다.
    async fn fetch_live_detail_response(&self, streamer_id: &str) -> Result<Response> {
        // x-www-form-urlencoded 형식의 본문을 만듭니다.
        let params = [("bid", streamer_id)];

        let request = api::with_form_headers(self.client.post(PLAYER_LIVE_API_URL)).form(&params);

        api::send(request).await
    }

    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 = api::with_user_agent(self.client.get(&url));

        let vod_response = api::send_json::<RawVODResponse>(request).await?;
        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 = api::with_form_headers(
            self.client
                .post("https://api.m.sooplive.co.kr/station/video/a/view"),
        )
        .form(&params);

        let vod_detail_response = api::send_json::<RawVODDetailResponse>(request).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 = api::with_user_agent(self.client.get(&url));

        api::send_text(request).await
    }

    async fn get_file_chat_events(
        &self,
        file: &VODFile,
        broad_start: &str,
        chunk_size_seconds: u64,
    ) -> (Vec<Event>, Vec<VodChatChunk>, Vec<VodChatChunkFailure>) {
        let duration_seconds = file.duration / 1_000_000; // 마이크로초를 초로 변환
        let mut all_events = Vec::new();
        let mut chunks = Vec::new();
        let mut failures = Vec::new();
        // ! 파일 시작점과 chunk size 계산 필요
        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) => {
                                let event_count = events.len();
                                all_events.append(&mut events);
                                chunks.push(VodChatChunk {
                                    file_id: file.id,
                                    file_order: file.order,
                                    start_time: current_time,
                                    event_count,
                                });
                            }
                            Err(e) => {
                                failures.push(VodChatChunkFailure {
                                    file_id: file.id,
                                    file_order: file.order,
                                    start_time: current_time,
                                    reason: e.to_string(),
                                });
                            }
                        }
                    }
                }
                Err(e) => {
                    failures.push(VodChatChunkFailure {
                        file_id: file.id,
                        file_order: file.order,
                        start_time: current_time,
                        reason: e.to_string(),
                    });
                }
            }
            current_time += chunk_size_seconds;
        }

        (all_events, chunks, failures)
    }

    pub async fn get_full_vod_chat(&self, vod_id: u64) -> Result<VodChatCollection> {
        let vod_detail = self.get_vod_detail(vod_id).await?;
        let mut all_events = Vec::new();
        let mut chunks = Vec::new();
        let mut failures = Vec::new();

        for file in vod_detail.files.iter() {
            let (mut file_events, mut file_chunks, mut file_failures) = self
                .get_file_chat_events(file, &vod_detail.broad_start, 300)
                .await; // 5분 간격
            all_events.append(&mut file_events);
            chunks.append(&mut file_chunks);
            failures.append(&mut file_failures);
        }

        Ok(VodChatCollection {
            events: all_events,
            chunks,
            failures,
        })
    }
}

impl Default for SoopHttpClient {
    fn default() -> Self {
        Self::new()
    }
}