krystv's picture
Upload 107 files
3374e90 verified
/*!
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<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>>,
}
// ============================================================================
// 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<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);
// 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<String> {
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<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)
}
/// 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<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()?;
// Try parsing as EncryptionResponse JSON
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());
}
}
}
}
// 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<Value, PluginError> {
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<String> {
Self::post_for_token(episode_id, "vid")
}
fn get_subtitle_token(episode_id: &str) -> Option<String> {
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<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 }
}
}
// ========================================================================
// SearchItem → MediaCard
// ========================================================================
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,
})
}
// ========================================================================
// Fetch items for a discover section
// ========================================================================
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(_) => {
// Try parsing as object with 'data' field
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()
}
// ========================================================================
// Handle video link → video tracks
// ========================================================================
fn handle_video_link(link: &str) -> Vec<VideoTrack> {
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<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,
};
// Try parsing as array of SubtitleItem
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;
}
// Try parsing as dictionary with potentially encrypted content
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
}
}
// ============================================================================
// Guest implementation
// ============================================================================
impl Guest for Component {
fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, 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<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()));
// 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<Vec<Server>, 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<StreamSource, PluginError> {
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<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)
}
}
// ============================================================================
// 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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&#x27;", "'")
}
bindings::export!(Component with_types_in bindings);