| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| #[allow(warnings)] |
| mod bindings; |
|
|
| use bindings::bex::plugin::common::*; |
| use bindings::bex::plugin::http; |
| use bindings::exports::api::Guest; |
| use serde::Deserialize; |
| use serde_json::Value; |
|
|
| struct Component; |
|
|
| const BASE_URL: &str = "https://kisskh.do"; |
| const ENC_STREAMS_ENDPOINT: &str = "https://enc-dec.app/api/enc-kisskh"; |
|
|
| |
| |
| |
|
|
| #[derive(Debug, Deserialize)] |
| struct SearchItem { |
| id: Option<Value>, |
| #[serde(alias = "_id")] |
| alt_id: Option<Value>, |
| title: Option<String>, |
| name: Option<String>, |
| thumbnail: Option<String>, |
| poster: Option<String>, |
| #[serde(rename = "type")] |
| item_type: Option<String>, |
| #[serde(rename = "episodesCount")] |
| episodes_count: Option<Value>, |
| label: Option<String>, |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| #[allow(dead_code)] |
| struct DramaDetails { |
| id: Option<i64>, |
| title: Option<String>, |
| description: Option<String>, |
| thumbnail: Option<String>, |
| #[serde(rename = "releaseDate")] |
| release_date: Option<String>, |
| status: Option<String>, |
| country: Option<String>, |
| #[serde(rename = "type")] |
| drama_type: Option<String>, |
| episodes: Option<Vec<EpisodeData>>, |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| struct EpisodeData { |
| id: Option<i64>, |
| #[serde(alias = "_id")] |
| alt_id: Option<String>, |
| number: Option<f64>, |
| #[serde(alias = "ep")] |
| ep_number: Option<f64>, |
| title: Option<String>, |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| struct EpisodeSources { |
| #[serde(rename = "Video")] |
| video: Option<String>, |
| #[serde(rename = "Video_tmp")] |
| video_tmp: Option<String>, |
| #[serde(rename = "ThirdParty")] |
| third_party: Option<String>, |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| #[allow(dead_code)] |
| struct EncryptionResponse { |
| status: Option<i32>, |
| result: Option<String>, |
| token: Option<String>, |
| data: Option<String>, |
| enc: Option<String>, |
| encrypted: Option<String>, |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| #[allow(dead_code)] |
| struct SubtitleItem { |
| label: Option<String>, |
| src: Option<String>, |
| default: Option<bool>, |
| land: Option<String>, |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| struct SectionResponse { |
| data: Option<Vec<SearchItem>>, |
| } |
|
|
| |
| |
| |
|
|
| struct SubtitleDecryptor; |
|
|
| impl SubtitleDecryptor { |
| const KEY1: &'static [u8] = b"AmSmZVcH93UQUezi"; |
| const KEY2: &'static [u8] = b"8056483646328763"; |
| const IV1_INT: [u32; 4] = [1382367819, 1465333859, 1902406224, 1164854838]; |
| const IV2_INT: [u32; 4] = [909653298, 909193779, 925905208, 892483379]; |
|
|
| fn int_array_to_bytes(int_array: &[u32; 4]) -> [u8; 16] { |
| let mut bytes = [0u8; 16]; |
| for (i, &val) in int_array.iter().enumerate() { |
| let b = val.to_be_bytes(); |
| bytes[i * 4..(i + 1) * 4].copy_from_slice(&b); |
| } |
| bytes |
| } |
|
|
| |
| |
| fn decrypt(encrypted_b64: &str) -> Option<String> { |
| let encrypted_bytes = base64_decode(encrypted_b64.trim())?; |
|
|
| let iv1 = Self::int_array_to_bytes(&Self::IV1_INT); |
| let iv2 = Self::int_array_to_bytes(&Self::IV2_INT); |
|
|
| |
| if let Some(result) = aes_cbc_decrypt(Self::KEY1, &iv1, &encrypted_bytes) { |
| return Some(result); |
| } |
| |
| if let Some(result) = aes_cbc_decrypt(Self::KEY2, &iv2, &encrypted_bytes) { |
| return Some(result); |
| } |
| None |
| } |
| } |
|
|
| |
| fn aes_cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Option<String> { |
| if data.len() < 16 || data.len() % 16 != 0 { |
| return None; |
| } |
|
|
| let mut result = Vec::with_capacity(data.len()); |
|
|
| |
| let round_keys = aes128_key_expansion(key)?; |
|
|
| |
| let mut prev_block = iv; |
| for chunk in data.chunks(16) { |
| let decrypted = aes128_decrypt_block(&round_keys, chunk); |
| |
| let mut plaintext_block = [0u8; 16]; |
| for i in 0..16 { |
| plaintext_block[i] = decrypted[i] ^ prev_block[i]; |
| } |
| result.extend_from_slice(&plaintext_block); |
| prev_block = chunk; |
| } |
|
|
| |
| let pad_len = *result.last()? as usize; |
| if pad_len == 0 || pad_len > 16 { |
| return None; |
| } |
| |
| for &b in &result[result.len() - pad_len..] { |
| if b as usize != pad_len { |
| return None; |
| } |
| } |
| result.truncate(result.len() - pad_len); |
|
|
| String::from_utf8(result).ok() |
| } |
|
|
| |
| fn base64_decode(input: &str) -> Option<Vec<u8>> { |
| const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; |
|
|
| let input = input.trim_end_matches('='); |
| let mut result = Vec::with_capacity(input.len() * 3 / 4); |
| let mut buf: u32 = 0; |
| let mut bits = 0u32; |
|
|
| for c in input.chars() { |
| let val = TABLE.iter().position(|&b| b as char == c)? as u32; |
| buf = (buf << 6) | val; |
| bits += 6; |
| if bits >= 8 { |
| bits -= 8; |
| result.push((buf >> bits) as u8); |
| } |
| } |
| Some(result) |
| } |
|
|
| |
| fn base64_encode(data: &[u8]) -> String { |
| const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; |
| let mut result = String::new(); |
| let mut i = 0; |
| while i + 2 < data.len() { |
| let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | (data[i + 2] as u32); |
| result.push(TABLE[((n >> 18) & 0x3F) as usize] as char); |
| result.push(TABLE[((n >> 12) & 0x3F) as usize] as char); |
| result.push(TABLE[((n >> 6) & 0x3F) as usize] as char); |
| result.push(TABLE[(n & 0x3F) as usize] as char); |
| i += 3; |
| } |
| if i + 1 < data.len() { |
| let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8); |
| result.push(TABLE[((n >> 18) & 0x3F) as usize] as char); |
| result.push(TABLE[((n >> 12) & 0x3F) as usize] as char); |
| result.push(TABLE[((n >> 6) & 0x3F) as usize] as char); |
| result.push('='); |
| } else if i < data.len() { |
| let n = (data[i] as u32) << 16; |
| result.push(TABLE[((n >> 18) & 0x3F) as usize] as char); |
| result.push(TABLE[((n >> 12) & 0x3F) as usize] as char); |
| result.push('='); |
| result.push('='); |
| } |
| result |
| } |
|
|
| |
| |
| |
|
|
| fn aes128_key_expansion(key: &[u8]) -> Option<[[u8; 16]; 11]> { |
| if key.len() != 16 { |
| return None; |
| } |
| const RCON: [u8; 10] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]; |
| let mut round_keys = [[0u8; 16]; 11]; |
| round_keys[0].copy_from_slice(key); |
|
|
| for i in 1..11 { |
| let prev = round_keys[i - 1]; |
| let mut w = [prev[13], prev[14], prev[15], prev[12]]; |
| |
| for b in w.iter_mut() { |
| *b = sbox(*b); |
| } |
| round_keys[i][0] = prev[0] ^ w[0] ^ RCON[i - 1]; |
| round_keys[i][1] = prev[1] ^ w[1]; |
| round_keys[i][2] = prev[2] ^ w[2]; |
| round_keys[i][3] = prev[3] ^ w[3]; |
| for j in 4..16 { |
| round_keys[i][j] = prev[j] ^ round_keys[i][j - 4]; |
| } |
| } |
| Some(round_keys) |
| } |
|
|
| fn aes128_decrypt_block(round_keys: &[[u8; 16]; 11], block: &[u8]) -> [u8; 16] { |
| let mut state = [0u8; 16]; |
| state.copy_from_slice(block); |
|
|
| |
| xor_round_key(&mut state, &round_keys[10]); |
|
|
| |
| for r in (1..10).rev() { |
| inv_shift_rows(&mut state); |
| inv_sub_bytes(&mut state); |
| xor_round_key(&mut state, &round_keys[r]); |
| inv_mix_columns(&mut state); |
| } |
|
|
| |
| inv_shift_rows(&mut state); |
| inv_sub_bytes(&mut state); |
| xor_round_key(&mut state, &round_keys[0]); |
|
|
| state |
| } |
|
|
| fn xor_round_key(state: &mut [u8; 16], key: &[u8; 16]) { |
| for i in 0..16 { |
| state[i] ^= key[i]; |
| } |
| } |
|
|
| fn inv_shift_rows(state: &mut [u8; 16]) { |
| |
| let tmp = state[13]; |
| state[13] = state[9]; state[9] = state[5]; state[5] = state[1]; state[1] = tmp; |
| |
| let tmp0 = state[2]; let tmp1 = state[6]; |
| state[2] = state[10]; state[6] = state[14]; state[10] = tmp0; state[14] = tmp1; |
| |
| let tmp = state[3]; |
| state[3] = state[7]; state[7] = state[11]; state[11] = state[15]; state[15] = tmp; |
| } |
|
|
| fn inv_sub_bytes(state: &mut [u8; 16]) { |
| for b in state.iter_mut() { |
| *b = inv_sbox(*b); |
| } |
| } |
|
|
| fn inv_mix_columns(state: &mut [u8; 16]) { |
| for i in (0..16).step_by(4) { |
| let s0 = state[i] as u32; |
| let s1 = state[i + 1] as u32; |
| let s2 = state[i + 2] as u32; |
| let s3 = state[i + 3] as u32; |
|
|
| state[i] = (gmul(0x0e, s0) ^ gmul(0x0b, s1) ^ gmul(0x0d, s2) ^ gmul(0x09, s3)) as u8; |
| state[i + 1] = (gmul(0x09, s0) ^ gmul(0x0e, s1) ^ gmul(0x0b, s2) ^ gmul(0x0d, s3)) as u8; |
| state[i + 2] = (gmul(0x0d, s0) ^ gmul(0x09, s1) ^ gmul(0x0e, s2) ^ gmul(0x0b, s3)) as u8; |
| state[i + 3] = (gmul(0x0b, s0) ^ gmul(0x0d, s1) ^ gmul(0x09, s2) ^ gmul(0x0e, s3)) as u8; |
| } |
| } |
|
|
| fn gmul(a: u32, b: u32) -> u32 { |
| let mut p = 0u32; |
| let mut a = a; |
| let mut b = b; |
| for _ in 0..8 { |
| if b & 1 != 0 { p ^= a; } |
| let hi = a & 0x80; |
| a <<= 1; |
| if hi != 0 { a ^= 0x1b; } |
| b >>= 1; |
| } |
| p |
| } |
|
|
| fn sbox(b: u8) -> u8 { |
| const SBOX: [u8; 256] = [ |
| 0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76, |
| 0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0, |
| 0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15, |
| 0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75, |
| 0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84, |
| 0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf, |
| 0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8, |
| 0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2, |
| 0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73, |
| 0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb, |
| 0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79, |
| 0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08, |
| 0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a, |
| 0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e, |
| 0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf, |
| 0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16, |
| ]; |
| SBOX[b as usize] |
| } |
|
|
| fn inv_sbox(b: u8) -> u8 { |
| const INV_SBOX: [u8; 256] = [ |
| 0x52,0x09,0x6a,0xd5,0x30,0x36,0xa5,0x38,0xbf,0x40,0xa3,0x9e,0x81,0xf3,0xd7,0xfb, |
| 0x7c,0xe3,0x39,0x82,0x9b,0x2f,0xff,0x87,0x34,0x8e,0x43,0x44,0xc4,0xde,0xe9,0xcb, |
| 0x54,0x7b,0x94,0x32,0xa6,0xc2,0x23,0x3d,0xee,0x4c,0x95,0x0b,0x42,0xfa,0xc3,0x4e, |
| 0x08,0x2e,0xa1,0x66,0x28,0xd9,0x24,0xb2,0x76,0x5b,0xa2,0x49,0x6d,0x8b,0xd1,0x25, |
| 0x72,0xf8,0xf6,0x64,0x86,0x68,0x98,0x16,0xd4,0xa4,0x5c,0xcc,0x5d,0x65,0xb6,0x92, |
| 0x6c,0x70,0x48,0x50,0xfd,0xed,0xb9,0xda,0x5e,0x15,0x46,0x57,0xa7,0x8d,0x9d,0x84, |
| 0x90,0xd8,0xab,0x00,0x8c,0xbc,0xd3,0x0a,0xf7,0xe4,0x58,0x05,0xb8,0xb3,0x45,0x06, |
| 0xd0,0x2c,0x1e,0x8f,0xca,0x3f,0x0f,0x02,0xc1,0xaf,0xbd,0x03,0x01,0x13,0x8a,0x6b, |
| 0x3a,0x91,0x11,0x41,0x4f,0x67,0xdc,0xea,0x97,0xf2,0xcf,0xce,0xf0,0xb4,0xe6,0x73, |
| 0x96,0xac,0x74,0x22,0xe7,0xad,0x35,0x85,0xe2,0xf9,0x37,0xe8,0x1c,0x75,0xdf,0x6e, |
| 0x47,0xf1,0x1a,0x71,0x1d,0x29,0xc5,0x89,0x6f,0xb7,0x62,0x0e,0xaa,0x18,0xbe,0x1b, |
| 0xfc,0x56,0x3e,0x4b,0xc6,0xd2,0x79,0x20,0x9a,0xdb,0xc0,0xfe,0x78,0xcd,0x5a,0xf4, |
| 0x1f,0xdd,0xa8,0x33,0x88,0x07,0xc7,0x31,0xb1,0x12,0x10,0x59,0x27,0x80,0xec,0x5f, |
| 0x60,0x51,0x7f,0xa9,0x19,0xb5,0x4a,0x0d,0x2d,0xe5,0x7a,0x9f,0x93,0xc9,0x9c,0xef, |
| 0xa0,0xe0,0x3b,0x4d,0xae,0x2a,0xf5,0xb0,0xc8,0xeb,0xbb,0x3c,0x83,0x53,0x99,0x61, |
| 0x17,0x2b,0x04,0x7e,0xba,0x77,0xd6,0x26,0xe1,0x69,0x14,0x63,0x55,0x21,0x0c,0x7d, |
| ]; |
| INV_SBOX[b as usize] |
| } |
|
|
| |
| |
| |
|
|
| impl Component { |
| fn get_headers() -> Vec<Attr> { |
| vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36".to_string() }, |
| Attr { key: "Accept-Language".to_string(), value: "en-US,en;q=0.9".to_string() }, |
| Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, |
| Attr { key: "Origin".to_string(), value: BASE_URL.to_string() }, |
| Attr { key: "Sec-Fetch-Dest".to_string(), value: "empty".to_string() }, |
| Attr { key: "Sec-Fetch-Mode".to_string(), value: "cors".to_string() }, |
| Attr { key: "Sec-Fetch-Site".to_string(), value: "same-origin".to_string() }, |
| ] |
| } |
|
|
| fn get_json_headers() -> Vec<Attr> { |
| let mut h = Self::get_headers(); |
| h.push(Attr { key: "Accept".to_string(), value: "application/json, text/javascript, */*; q=0.01".to_string() }); |
| h.push(Attr { key: "X-Requested-With".to_string(), value: "XMLHttpRequest".to_string() }); |
| h |
| } |
|
|
| #[allow(dead_code)] |
| fn fetch_url(url: &str) -> Result<String, PluginError> { |
| Self::fetch_url_with_headers(url, Self::get_headers()) |
| } |
|
|
| fn fetch_json_url(url: &str) -> Result<String, PluginError> { |
| Self::fetch_url_with_headers(url, Self::get_json_headers()) |
| } |
|
|
| fn fetch_url_with_headers(url: &str, headers: Vec<Attr>) -> Result<String, PluginError> { |
| let req = http::Request { |
| method: http::Method::Get, |
| url: url.to_string(), |
| headers, |
| body: None, |
| timeout_ms: Some(15000), |
| follow_redirects: true, |
| cache_mode: http::CacheMode::Normal, |
| max_bytes: Some(5 * 1024 * 1024), |
| }; |
|
|
| match http::send_request(&req) { |
| Ok(resp) if resp.status == 200 => String::from_utf8(resp.body) |
| .map_err(|e| PluginError::Parse(format!("UTF-8 error: {}", e))), |
| Ok(resp) => Err(PluginError::Network(format!("HTTP {}", resp.status))), |
| Err(e) => Err(e), |
| } |
| } |
|
|
| fn post_for_token(episode_id: &str, token_type: &str) -> Option<String> { |
| let url = format!("{}?text={}&type={}", ENC_STREAMS_ENDPOINT, episode_id, token_type); |
| let headers = vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string() }, |
| Attr { key: "Accept".to_string(), value: "application/json".to_string() }, |
| Attr { key: "Content-Type".to_string(), value: "application/x-www-form-urlencoded".to_string() }, |
| ]; |
|
|
| let req = http::Request { |
| method: http::Method::Post, |
| url, |
| headers, |
| body: None, |
| timeout_ms: Some(10000), |
| follow_redirects: true, |
| cache_mode: http::CacheMode::NoStore, |
| max_bytes: Some(1024 * 1024), |
| }; |
|
|
| let resp = http::send_request(&req).ok()?; |
| if resp.status < 200 || resp.status >= 300 { |
| return None; |
| } |
| let body = String::from_utf8(resp.body).ok()?; |
|
|
| |
| if let Ok(enc_resp) = serde_json::from_str::<EncryptionResponse>(&body) { |
| for field in [&enc_resp.result, &enc_resp.token, &enc_resp.data, &enc_resp.enc, &enc_resp.encrypted] { |
| if let Some(ref val) = field { |
| if !val.is_empty() { |
| return Some(val.clone()); |
| } |
| } |
| } |
| } |
|
|
| |
| let trimmed = body.trim().trim_matches('"'); |
| if !trimmed.is_empty() && !trimmed.starts_with('{') { |
| return Some(trimmed.to_string()); |
| } |
| None |
| } |
|
|
| #[allow(dead_code)] |
| fn fetch_json_value(url: &str) -> Result<Value, PluginError> { |
| let body = Self::fetch_json_url(url)?; |
| serde_json::from_str(&body).map_err(|e| PluginError::Parse(format!("JSON error: {}", e))) |
| } |
|
|
| |
| |
| |
|
|
| fn get_kkey(episode_id: &str) -> Option<String> { |
| Self::post_for_token(episode_id, "vid") |
| } |
|
|
| fn get_subtitle_token(episode_id: &str) -> Option<String> { |
| Self::post_for_token(episode_id, "sub") |
| } |
|
|
| |
| |
| |
|
|
| fn parse_media_kind(type_str: Option<&str>) -> MediaKind { |
| match type_str { |
| Some(s) => { |
| let lower = s.to_lowercase(); |
| if lower.contains("movie") { |
| MediaKind::Movie |
| } else if lower.contains("tv") || lower.contains("series") || lower.contains("drama") { |
| MediaKind::Series |
| } else if lower.contains("anime") { |
| MediaKind::Anime |
| } else if lower.contains("ova") || lower.contains("special") { |
| MediaKind::Special |
| } else { |
| MediaKind::Unknown |
| } |
| } |
| None => MediaKind::Unknown, |
| } |
| } |
|
|
| |
| |
| |
|
|
| fn extract_id(val: Option<&Value>) -> Option<String> { |
| let v = val?; |
| if let Some(s) = v.as_str() { |
| Some(s.to_string()) |
| } else if let Some(n) = v.as_i64() { |
| Some(n.to_string()) |
| } else if let Some(f) = v.as_f64() { |
| Some(f.to_string()) |
| } else { |
| let s = v.to_string(); |
| let trimmed = s.trim_matches('"').to_string(); |
| if !trimmed.is_empty() { Some(trimmed) } else { None } |
| } |
| } |
|
|
| |
| |
| |
|
|
| fn search_item_to_card(item: &SearchItem) -> Option<MediaCard> { |
| let item_id = Self::extract_id(item.id.as_ref().or(item.alt_id.as_ref()))?; |
| let title = item.title.as_deref().or(item.name.as_deref())?.to_string(); |
|
|
| let thumbnail = item.thumbnail.as_deref().or(item.poster.as_deref()).unwrap_or(""); |
|
|
| let mut extra_info = Vec::new(); |
| if let Some(ref count_val) = item.episodes_count { |
| let count_str = if let Some(n) = count_val.as_i64() { |
| n.to_string() |
| } else if let Some(s) = count_val.as_str() { |
| s.to_string() |
| } else { |
| count_val.to_string() |
| }; |
| extra_info.push(Attr { key: "episodes_count".to_string(), value: format!("EP {}", count_str) }); |
| } |
| if let Some(ref lbl) = item.label { |
| if !lbl.trim().is_empty() { |
| extra_info.push(Attr { key: "label".to_string(), value: lbl.clone() }); |
| } |
| } |
|
|
| let kind = Self::parse_media_kind(item.item_type.as_deref()); |
|
|
| Some(MediaCard { |
| id: item_id.clone(), |
| title: decode_html_entities(&title), |
| kind: Some(kind), |
| images: if thumbnail.is_empty() { |
| None |
| } else { |
| Some(make_image_set(thumbnail, ImageLayout::Landscape)) |
| }, |
| original_title: None, |
| tagline: None, |
| year: None, |
| score: None, |
| genres: vec![], |
| status: None, |
| content_rating: None, |
| url: Some(format!("{}/Drama/{}/{}", BASE_URL, title.replace(' ', "-"), item_id)), |
| ids: vec![], |
| extra: extra_info, |
| }) |
| } |
|
|
| |
| |
| |
|
|
| fn fetch_section_items(url: &str) -> Vec<MediaCard> { |
| let json_str = match Self::fetch_json_url(url) { |
| Ok(s) => s, |
| Err(_) => return vec![], |
| }; |
|
|
| let items: Vec<SearchItem> = match serde_json::from_str(&json_str) { |
| Ok(items) => items, |
| Err(_) => { |
| |
| match serde_json::from_str::<SectionResponse>(&json_str) { |
| Ok(resp) => resp.data.unwrap_or_default(), |
| Err(_) => return vec![], |
| } |
| } |
| }; |
|
|
| items.iter().filter_map(Self::search_item_to_card).collect() |
| } |
|
|
| |
| |
| |
|
|
| fn handle_video_link(link: &str) -> Vec<VideoTrack> { |
| let mut videos = Vec::new(); |
| if link.is_empty() { |
| return videos; |
| } |
|
|
| |
| let mut clean_url = link.to_string(); |
| let subtitle_patterns = ["&sub_", "&c1_label=", "&c2_label=", "&caption_"]; |
| for pattern in subtitle_patterns { |
| if let Some(idx) = clean_url.find(pattern) { |
| clean_url = clean_url[..idx].to_string(); |
| break; |
| } |
| } |
|
|
| let url_path = clean_url.split('?').next().unwrap_or(&clean_url); |
|
|
| if url_path.ends_with(".m3u8") { |
| videos.push(VideoTrack { |
| resolution: VideoResolution { width: 1920, height: 1080, hdr: false, label: "Auto (HLS)".to_string() }, |
| url: clean_url, |
| mime_type: Some("application/vnd.apple.mpegurl".to_string()), |
| bitrate: None, |
| codecs: None, |
| }); |
| } else if url_path.ends_with(".mp4") { |
| let (w, h, lbl) = if clean_url.contains("1080") { (1920, 1080, "1080p") } |
| else if clean_url.contains("720") { (1280, 720, "720p") } |
| else if clean_url.contains("480") { (854, 480, "480p") } |
| else { (1280, 720, "720p") }; |
| videos.push(VideoTrack { |
| resolution: VideoResolution { width: w, height: h, hdr: false, label: lbl.to_string() }, |
| url: clean_url, |
| mime_type: Some("video/mp4".to_string()), |
| bitrate: None, |
| codecs: None, |
| }); |
| } else if clean_url.contains('=') && clean_url.contains("http") { |
| let has_caption_param = ["caption_", "subtitle", "c1_label", "c2_label", "sub_"] |
| .iter().any(|&p| clean_url.to_lowercase().contains(p)); |
| if !has_caption_param { |
| if let Some(idx) = clean_url.find("=http") { |
| let mut embedded_url = clean_url[idx + 1..].to_string(); |
| for pattern in subtitle_patterns { |
| if let Some(s_idx) = embedded_url.find(pattern) { |
| embedded_url = embedded_url[..s_idx].to_string(); |
| break; |
| } |
| } |
| videos.push(VideoTrack { |
| resolution: VideoResolution { width: 1280, height: 720, hdr: false, label: "Extracted".to_string() }, |
| url: embedded_url, |
| mime_type: None, |
| bitrate: None, |
| codecs: None, |
| }); |
| } |
| } |
| } else { |
| let (w, h, lbl) = if clean_url.contains("1080") { (1920, 1080, "1080p") } |
| else if clean_url.contains("720") { (1280, 720, "720p") } |
| else if clean_url.contains("480") { (854, 480, "480p") } |
| else { (1920, 1080, "Auto") }; |
| videos.push(VideoTrack { |
| resolution: VideoResolution { width: w, height: h, hdr: false, label: lbl.to_string() }, |
| url: clean_url, |
| mime_type: None, |
| bitrate: None, |
| codecs: None, |
| }); |
| } |
|
|
| videos |
| } |
|
|
| |
| |
| |
|
|
| fn fetch_subtitles(episode_id: &str) -> Vec<SubtitleTrack> { |
| let mut subtitles = Vec::new(); |
| let sub_token = match Self::get_subtitle_token(episode_id) { |
| Some(t) => t, |
| None => return subtitles, |
| }; |
|
|
| let url = format!("{}/api/Sub/{}?kkey={}", BASE_URL, episode_id, sub_token); |
| let json_str = match Self::fetch_json_url(&url) { |
| Ok(s) => s, |
| Err(_) => return subtitles, |
| }; |
|
|
| |
| if let Ok(items) = serde_json::from_str::<Vec<SubtitleItem>>(&json_str) { |
| for item in items { |
| if let (Some(label), Some(src)) = (item.label, item.src) { |
| subtitles.push(SubtitleTrack { |
| label, |
| url: src, |
| language: item.land, |
| format: Some("vtt".to_string()), |
| }); |
| } |
| } |
| return subtitles; |
| } |
|
|
| |
| if let Ok(map) = serde_json::from_str::<serde_json::Map<String, Value>>(&json_str) { |
| for (key, value) in map { |
| if let Some(val_str) = value.as_str() { |
| if let Some(decrypted) = SubtitleDecryptor::decrypt(val_str) { |
| let encoded = base64_encode(decrypted.as_bytes()); |
| let data_url = format!("data:text/vtt;base64,{}", encoded); |
| subtitles.push(SubtitleTrack { |
| label: key, |
| url: data_url, |
| language: None, |
| format: Some("vtt".to_string()), |
| }); |
| } else if val_str.starts_with("http") { |
| subtitles.push(SubtitleTrack { |
| label: key, |
| url: val_str.to_string(), |
| language: None, |
| format: Some("vtt".to_string()), |
| }); |
| } |
| } |
| } |
| } |
|
|
| subtitles |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Guest for Component { |
| fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> { |
| let mut sections = Vec::new(); |
|
|
| |
| sections.push(HomeSection { |
| id: "categories".to_string(), |
| title: "Browse".to_string(), |
| subtitle: None, |
| items: vec![], |
| next_page: None, |
| layout: CardLayout::Grid, |
| show_rank: false, |
| categories: vec![ |
| CategoryLink { id: "type=3".to_string(), title: "Anime".to_string(), subtitle: None, image: None }, |
| CategoryLink { id: "type=1".to_string(), title: "Asian Drama".to_string(), subtitle: None, image: None }, |
| ], |
| extra: vec![], |
| }); |
|
|
| |
| let section_configs = vec![ |
| ("last_update", "Latest Update", format!("{}/api/DramaList/LastUpdate?ispc=true", BASE_URL)), |
| ("most_viewed", "Popular", format!("{}/api/DramaList/MostView?ispc=true&c=1", BASE_URL)), |
| ("top_rating", "Top Rating", format!("{}/api/DramaList/TopRating?ispc=true", BASE_URL)), |
| ("upcoming", "Upcoming", format!("{}/api/DramaList/Upcoming?ispc=true", BASE_URL)), |
| ("animate", "Anime (Popular)", format!("{}/api/DramaList/Animate?ispc=true", BASE_URL)), |
| ]; |
|
|
| for (sec_id, sec_title, sec_url) in section_configs { |
| let items = Self::fetch_section_items(&sec_url); |
| if !items.is_empty() { |
| sections.push(HomeSection { |
| id: sec_id.to_string(), |
| title: sec_title.to_string(), |
| subtitle: None, |
| items: items.into_iter().take(20).collect(), |
| next_page: Some("1".to_string()), |
| layout: CardLayout::Grid, |
| show_rank: sec_id == "most_viewed", |
| categories: vec![], |
| extra: vec![], |
| }); |
| } |
| } |
|
|
| Ok(sections) |
| } |
|
|
| fn get_category(_ctx: RequestContext, id: String, page: PageCursor) -> Result<PagedResult, PluginError> { |
| let page_num: u32 = page.token.as_deref().and_then(|t| t.parse().ok()).unwrap_or(1); |
|
|
| let url = if id.starts_with("type=") { |
| let type_id = id.strip_prefix("type=").unwrap_or("0"); |
| format!("{}/api/DramaList/List?type={}&page={}", BASE_URL, type_id, page_num) |
| } else { |
| match id.as_str() { |
| "last_update" => format!("{}/api/DramaList/LastUpdate?ispc=true&page={}", BASE_URL, page_num), |
| "most_viewed" => format!("{}/api/DramaList/MostView?ispc=true&c=1&page={}", BASE_URL, page_num), |
| "top_rating" => format!("{}/api/DramaList/TopRating?ispc=true&page={}", BASE_URL, page_num), |
| "upcoming" => format!("{}/api/DramaList/Upcoming?ispc=true&page={}", BASE_URL, page_num), |
| "animate" => format!("{}/api/DramaList/Animate?ispc=true&page={}", BASE_URL, page_num), |
| _ => return Err(PluginError::NotFound), |
| } |
| }; |
|
|
| let items = Self::fetch_section_items(&url); |
| let next_page = if !items.is_empty() { |
| Some((page_num + 1).to_string()) |
| } else { |
| None |
| }; |
|
|
| Ok(PagedResult { |
| items, |
| categories: vec![], |
| next_page, |
| }) |
| } |
|
|
| fn search(_ctx: RequestContext, query: String, _filters: SearchFilters) -> Result<PagedResult, PluginError> { |
| if query.trim().is_empty() { |
| return Ok(PagedResult { items: vec![], categories: vec![], next_page: None }); |
| } |
|
|
| let encoded = query.replace(' ', "+"); |
| let url = format!("{}/api/DramaList/Search?q={}&type=0", BASE_URL, encoded); |
|
|
| let json_str = Self::fetch_json_url(&url)?; |
|
|
| let items: Vec<SearchItem> = match serde_json::from_str(&json_str) { |
| Ok(items) => items, |
| Err(_) => { |
| match serde_json::from_str::<SectionResponse>(&json_str) { |
| Ok(resp) => resp.data.unwrap_or_default(), |
| Err(_) => return Ok(PagedResult { items: vec![], categories: vec![], next_page: None }), |
| } |
| } |
| }; |
|
|
| let cards: Vec<MediaCard> = items.iter().filter_map(Self::search_item_to_card).collect(); |
|
|
| Ok(PagedResult { |
| items: cards, |
| categories: vec![], |
| next_page: None, |
| }) |
| } |
|
|
| fn get_info(_ctx: RequestContext, id: String) -> Result<MediaInfo, PluginError> { |
| let url = format!("{}/api/DramaList/Drama/{}?isq=false", BASE_URL, id); |
| let json_str = Self::fetch_json_url(&url)?; |
|
|
| let drama: DramaDetails = serde_json::from_str(&json_str) |
| .map_err(|e| PluginError::Parse(format!("Invalid drama response: {}", e)))?; |
|
|
| let title = drama.title.unwrap_or_else(|| "Unknown".to_string()); |
| let year = drama.release_date.as_ref() |
| .and_then(|date| date.split('-').next().map(|y| y.to_string())); |
|
|
| |
| let mut episodes = Vec::new(); |
| if let Some(ep_list) = drama.episodes { |
| for ep_data in ep_list { |
| let ep_id = ep_data.id.map(|id| id.to_string()) |
| .or(ep_data.alt_id.clone()) |
| .unwrap_or_default(); |
|
|
| if ep_id.is_empty() { |
| continue; |
| } |
|
|
| let ep_number = ep_data.number.or(ep_data.ep_number).unwrap_or(0.0); |
| let ep_title = ep_data.title |
| .unwrap_or_else(|| format!("Episode {}", ep_number as i32)); |
|
|
| episodes.push(Episode { |
| id: ep_id, |
| title: ep_title, |
| number: Some(ep_number), |
| season: None, |
| images: None, |
| description: None, |
| released: None, |
| score: None, |
| url: None, |
| tags: vec![], |
| extra: vec![], |
| }); |
| } |
| } |
|
|
| let kind = Self::parse_media_kind(drama.drama_type.as_deref()); |
| let thumbnail = drama.thumbnail.as_deref().unwrap_or(""); |
|
|
| |
| let status = drama.status.as_deref().and_then(|s| match s.to_lowercase().as_str() { |
| "ongoing" => Some(Status::Ongoing), |
| "completed" => Some(Status::Completed), |
| "upcoming" => Some(Status::Upcoming), |
| "cancelled" => Some(Status::Cancelled), |
| _ => None, |
| }); |
|
|
| Ok(MediaInfo { |
| id: id.clone(), |
| title: decode_html_entities(&title), |
| kind, |
| images: if thumbnail.is_empty() { |
| None |
| } else { |
| Some(make_image_set(thumbnail, ImageLayout::Portrait)) |
| }, |
| original_title: None, |
| description: drama.description.map(|d| decode_html_entities(&d)), |
| score: None, |
| scored_by: None, |
| year, |
| release_date: drama.release_date.clone(), |
| genres: vec![], |
| tags: vec![], |
| status, |
| content_rating: None, |
| seasons: if episodes.is_empty() { vec![] } else { |
| vec![Season { |
| id: format!("{}_s1", id), |
| title: "Season 1".to_string(), |
| number: Some(1.0), |
| year: None, |
| episodes, |
| }] |
| }, |
| cast: vec![], |
| crew: vec![], |
| runtime_minutes: None, |
| trailer_url: None, |
| ids: vec![], |
| studio: None, |
| country: drama.country.clone(), |
| language: drama.country.clone(), |
| url: Some(format!("{}/Drama/{}/{}", BASE_URL, title.replace(' ', "-"), id)), |
| extra: vec![], |
| }) |
| } |
|
|
| fn get_servers(_ctx: RequestContext, id: String) -> Result<Vec<Server>, PluginError> { |
| |
| |
| let server_id = id; |
|
|
| if server_id.is_empty() { |
| return Err(PluginError::InvalidInput("Empty episode ID".to_string())); |
| } |
|
|
| Ok(vec![Server { |
| id: server_id.clone(), |
| label: "KissKh".to_string(), |
| url: format!("{}/api/DramaList/Episode/{}.png?err=false", BASE_URL, server_id), |
| priority: 1, |
| extra: vec![], |
| }]) |
| } |
|
|
| fn resolve_stream(_ctx: RequestContext, server: Server) -> Result<StreamSource, PluginError> { |
| let episode_id = &server.id; |
|
|
| |
| let kkey = Self::get_kkey(episode_id) |
| .ok_or_else(|| PluginError::Network("Failed to get encryption token".to_string()))?; |
|
|
| |
| let url = format!( |
| "{}/api/DramaList/Episode/{}.png?err=false&ts=&time=&kkey={}", |
| BASE_URL, episode_id, kkey |
| ); |
|
|
| let json_str = Self::fetch_json_url(&url)?; |
| let sources: EpisodeSources = serde_json::from_str(&json_str) |
| .map_err(|e| PluginError::Parse(format!("Invalid episode sources: {}", e)))?; |
|
|
| |
| let mut videos = Vec::new(); |
| if let Some(ref video_url) = sources.video { |
| videos.extend(Self::handle_video_link(video_url)); |
| } |
| if let Some(ref video_tmp) = sources.video_tmp { |
| videos.extend(Self::handle_video_link(video_tmp)); |
| } |
| if let Some(ref third_party) = sources.third_party { |
| videos.extend(Self::handle_video_link(third_party)); |
| } |
|
|
| if videos.is_empty() { |
| return Err(PluginError::NotFound); |
| } |
|
|
| |
| let format = if videos[0].url.contains(".m3u8") { |
| StreamFormat::Hls |
| } else { |
| StreamFormat::Progressive |
| }; |
|
|
| let manifest_url = if format == StreamFormat::Hls { |
| Some(videos[0].url.clone()) |
| } else { |
| None |
| }; |
|
|
| |
| let subtitles = Self::fetch_subtitles(episode_id); |
|
|
| Ok(StreamSource { |
| id: format!("stream-{}", episode_id), |
| label: server.label.clone(), |
| format, |
| manifest_url, |
| videos, |
| subtitles, |
| headers: vec![ |
| Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string() }, |
| ], |
| extra: vec![], |
| }) |
| } |
|
|
| fn search_subtitles(_ctx: RequestContext, _query: SubtitleQuery) -> Result<Vec<SubtitleEntry>, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
|
|
| fn download_subtitle(_ctx: RequestContext, _id: String) -> Result<SubtitleFile, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
|
|
| fn get_articles(_ctx: RequestContext) -> Result<Vec<ArticleSection>, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
|
|
| fn search_articles(_ctx: RequestContext, _query: String) -> Result<Vec<Article>, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
| } |
|
|
| |
| |
| |
|
|
| fn make_image_set(url: &str, layout: ImageLayout) -> ImageSet { |
| let img = Image { |
| url: url.to_string(), |
| layout, |
| width: None, |
| height: None, |
| blurhash: None, |
| }; |
| ImageSet { |
| low: Some(img.clone()), |
| medium: Some(img.clone()), |
| high: Some(img), |
| backdrop: None, |
| logo: None, |
| } |
| } |
|
|
| fn decode_html_entities(s: &str) -> String { |
| s.replace("&", "&") |
| .replace("<", "<") |
| .replace(">", ">") |
| .replace(""", "\"") |
| .replace("'", "'") |
| .replace("'", "'") |
| .replace("'", "'") |
| } |
|
|
| bindings::export!(Component with_types_in bindings); |
|
|