/*! KissKh streaming plugin for kisskh.do Properly reverse-engineered from reference implementation. Features: - Search anime/drama with JSON API - Fetch detailed information with episodes - Extract video sources from multiple servers - Token-based authentication via enc-dec.app - AES-128-CBC subtitle decryption - HLS and progressive stream support */ #[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"; // ============================================================================ // KissKh API Response Types // ============================================================================ #[derive(Debug, Deserialize)] struct SearchItem { id: Option, #[serde(alias = "_id")] alt_id: Option, title: Option, name: Option, thumbnail: Option, poster: Option, #[serde(rename = "type")] item_type: Option, #[serde(rename = "episodesCount")] episodes_count: Option, label: Option, } #[derive(Debug, Deserialize)] #[allow(dead_code)] struct DramaDetails { id: Option, title: Option, description: Option, thumbnail: Option, #[serde(rename = "releaseDate")] release_date: Option, status: Option, country: Option, #[serde(rename = "type")] drama_type: Option, episodes: Option>, } #[derive(Debug, Deserialize)] struct EpisodeData { id: Option, #[serde(alias = "_id")] alt_id: Option, number: Option, #[serde(alias = "ep")] ep_number: Option, title: Option, } #[derive(Debug, Deserialize)] struct EpisodeSources { #[serde(rename = "Video")] video: Option, #[serde(rename = "Video_tmp")] video_tmp: Option, #[serde(rename = "ThirdParty")] third_party: Option, } #[derive(Debug, Deserialize)] #[allow(dead_code)] struct EncryptionResponse { status: Option, result: Option, token: Option, data: Option, enc: Option, encrypted: Option, } #[derive(Debug, Deserialize)] #[allow(dead_code)] struct SubtitleItem { label: Option, src: Option, default: Option, land: Option, } #[derive(Debug, Deserialize)] struct SectionResponse { data: Option>, } // ============================================================================ // AES-128-CBC Subtitle Decryption // ============================================================================ 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 } /// Decrypt a base64-encoded AES-128-CBC encrypted string. /// Tries KEY1/IV1 first, then KEY2/IV2. fn decrypt(encrypted_b64: &str) -> Option { 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); // Try KEY1+IV1 first if let Some(result) = aes_cbc_decrypt(Self::KEY1, &iv1, &encrypted_bytes) { return Some(result); } // Try KEY2+IV2 if let Some(result) = aes_cbc_decrypt(Self::KEY2, &iv2, &encrypted_bytes) { return Some(result); } None } } /// AES-128-CBC decryption with PKCS7 unpadding (pure Rust, no external deps) fn aes_cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Option { if data.len() < 16 || data.len() % 16 != 0 { return None; } let mut result = Vec::with_capacity(data.len()); // AES-128 key expansion let round_keys = aes128_key_expansion(key)?; // CBC mode decryption let mut prev_block = iv; for chunk in data.chunks(16) { let decrypted = aes128_decrypt_block(&round_keys, chunk); // XOR with previous ciphertext block (or IV for first block) 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; } // PKCS7 unpadding let pad_len = *result.last()? as usize; if pad_len == 0 || pad_len > 16 { return None; } // Verify all padding bytes 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() } /// Base64 decode (minimal implementation, no external dep) fn base64_decode(input: &str) -> Option> { 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) } /// Base64 encode (minimal implementation) 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 } // ============================================================================ // AES-128 Implementation (pure Rust, no external deps for WASM compatibility) // ============================================================================ 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]]; // RotWord // SubBytes 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); // Initial round key addition (last round key) xor_round_key(&mut state, &round_keys[10]); // Rounds 9 down to 1 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); } // Final round 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]) { // Row 1: shift right by 1 let tmp = state[13]; state[13] = state[9]; state[9] = state[5]; state[5] = state[1]; state[1] = tmp; // Row 2: shift right by 2 let tmp0 = state[2]; let tmp1 = state[6]; state[2] = state[10]; state[6] = state[14]; state[10] = tmp0; state[14] = tmp1; // Row 3: shift right by 3 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] } // ============================================================================ // HTTP helpers — delegate to host // ============================================================================ impl Component { fn get_headers() -> Vec { 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 { 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 { Self::fetch_url_with_headers(url, Self::get_headers()) } fn fetch_json_url(url: &str) -> Result { Self::fetch_url_with_headers(url, Self::get_json_headers()) } fn fetch_url_with_headers(url: &str, headers: Vec) -> Result { 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 { 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()?; // Try parsing as EncryptionResponse JSON if let Ok(enc_resp) = serde_json::from_str::(&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()); } } } } // Fallback: plain string 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 { let body = Self::fetch_json_url(url)?; serde_json::from_str(&body).map_err(|e| PluginError::Parse(format!("JSON error: {}", e))) } // ======================================================================== // Token encryption // ======================================================================== fn get_kkey(episode_id: &str) -> Option { Self::post_for_token(episode_id, "vid") } fn get_subtitle_token(episode_id: &str) -> Option { Self::post_for_token(episode_id, "sub") } // ======================================================================== // Parse media type from string // ======================================================================== 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, } } // ======================================================================== // Extract item ID from flexible JSON value // ======================================================================== fn extract_id(val: Option<&Value>) -> Option { 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 } } } // ======================================================================== // SearchItem → MediaCard // ======================================================================== fn search_item_to_card(item: &SearchItem) -> Option { 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, }) } // ======================================================================== // Fetch items for a discover section // ======================================================================== fn fetch_section_items(url: &str) -> Vec { let json_str = match Self::fetch_json_url(url) { Ok(s) => s, Err(_) => return vec![], }; let items: Vec = match serde_json::from_str(&json_str) { Ok(items) => items, Err(_) => { // Try parsing as object with 'data' field match serde_json::from_str::(&json_str) { Ok(resp) => resp.data.unwrap_or_default(), Err(_) => return vec![], } } }; items.iter().filter_map(Self::search_item_to_card).collect() } // ======================================================================== // Handle video link → video tracks // ======================================================================== fn handle_video_link(link: &str) -> Vec { let mut videos = Vec::new(); if link.is_empty() { return videos; } // Clean up URL - remove subtitle parameters 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 } // ======================================================================== // Fetch subtitles for an episode // ======================================================================== fn fetch_subtitles(episode_id: &str) -> Vec { 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, }; // Try parsing as array of SubtitleItem if let Ok(items) = serde_json::from_str::>(&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; } // Try parsing as dictionary with potentially encrypted content if let Ok(map) = serde_json::from_str::>(&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 } } // ============================================================================ // Guest implementation // ============================================================================ impl Guest for Component { fn get_home(_ctx: RequestContext) -> Result, PluginError> { let mut sections = Vec::new(); // Category links 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![], }); // Fetch sections from API 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 { 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 { 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 = match serde_json::from_str(&json_str) { Ok(items) => items, Err(_) => { match serde_json::from_str::(&json_str) { Ok(resp) => resp.data.unwrap_or_default(), Err(_) => return Ok(PagedResult { items: vec![], categories: vec![], next_page: None }), } } }; let cards: Vec = 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 { 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())); // Parse episodes 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(""); // Status parsing 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, PluginError> { // KissKh uses the episode ID directly as the server ID // The id parameter IS the server ID in KissKh's API 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 { let episode_id = &server.id; // Step 1: Get encrypted token let kkey = Self::get_kkey(episode_id) .ok_or_else(|| PluginError::Network("Failed to get encryption token".to_string()))?; // Step 2: Fetch episode sources with token 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)))?; // Collect videos from all sources 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); } // Determine format from first video 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 }; // Fetch subtitles 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, PluginError> { Err(PluginError::Unsupported) } fn download_subtitle(_ctx: RequestContext, _id: String) -> Result { Err(PluginError::Unsupported) } fn get_articles(_ctx: RequestContext) -> Result, PluginError> { Err(PluginError::Unsupported) } fn search_articles(_ctx: RequestContext, _query: String) -> Result, PluginError> { Err(PluginError::Unsupported) } } // ============================================================================ // Utility functions // ============================================================================ 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);