use crate::DbPool; use crate::api::ClientVersion; use crate::providers::{ Provider, report_provider_error, report_provider_error_background, requester_or_default, }; use crate::status::*; use crate::util::cache::VideoCache; use crate::util::hoster_proxy::{proxy_name_for_url, rewrite_hoster_url}; use crate::util::parse_abbreviated_number; use crate::util::requester::Requester; use crate::videos::{ServerOptions, VideoItem}; use async_trait::async_trait; use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, Utc}; use error_chain::error_chain; use futures::stream::{self, StreamExt}; use htmlentity::entity::ICodedDataTrait; use regex::Regex; use std::sync::{Arc, RwLock}; use std::time::Duration as StdDuration; use std::{thread, vec}; use tokio::time::timeout; use url::Url; use wreq::Version; pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = crate::providers::ProviderChannelMetadata { group_id: "jav", tags: &["jav", "asian", "metadata"], }; error_chain! { foreign_links { Io(std::io::Error); } errors { Parse(msg: String) { description("parse error") display("parse error: {}", msg) } } } const BASE_URL: &str = "https://sextb.net"; const CHANNEL_ID: &str = "sextb"; #[derive(Debug, Clone)] pub struct SextbProvider { url: String, genres: Arc>>, studios: Arc>>, actresses: Arc>>, } #[derive(Debug, Clone)] enum Target { Section { section: String, sort: String, }, Search { slug: String, }, Genre { slug: String, }, Studio { slug: String, }, Actress { slug: String, }, } #[derive(Debug, Clone, Default)] struct ListItemSeed { id: String, title: String, url: String, thumb: String, duration: u32, quality: Option, code: Option, tags: Vec, } #[derive(Debug, Clone, Default)] struct DetailMetadata { aliases: Vec, director: Option, label: Option, studio: Option<(String, String)>, cast: Vec<(String, String)>, genres: Vec<(String, String)>, quality: Option, release_date: Option, added_at: Option, views: Option, rating: Option, } #[derive(Debug, Clone, PartialEq, Eq)] struct PlayerEpisode { label: String, film_id: String, episode_id: String, } impl SextbProvider { pub fn new() -> Self { let provider = Self { url: BASE_URL.to_string(), genres: Arc::new(RwLock::new(vec![FilterOption { id: "all".to_string(), title: "All".to_string(), }])), studios: Arc::new(RwLock::new(vec![FilterOption { id: "all".to_string(), title: "All".to_string(), }])), actresses: Arc::new(RwLock::new(vec![FilterOption { id: "all".to_string(), title: "All".to_string(), }])), }; provider.spawn_initial_load(); provider } fn spawn_initial_load(&self) { let url = self.url.clone(); let genres = Arc::clone(&self.genres); let studios = Arc::clone(&self.studios); let actresses = Arc::clone(&self.actresses); thread::spawn(move || { let runtime = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() { Ok(runtime) => runtime, Err(error) => { report_provider_error_background( CHANNEL_ID, "spawn_initial_load.runtime_build", &error.to_string(), ); return; } }; runtime.block_on(async move { if let Err(error) = Self::load_genres(&url, Arc::clone(&genres)).await { report_provider_error_background(CHANNEL_ID, "load_genres", &error.to_string()); } if let Err(error) = Self::load_studios(&url, Arc::clone(&studios)).await { report_provider_error_background( CHANNEL_ID, "load_studios", &error.to_string(), ); } if let Err(error) = Self::load_actresses(&url, Arc::clone(&actresses)).await { report_provider_error_background( CHANNEL_ID, "load_actresses", &error.to_string(), ); } }); }); } fn build_channel(&self, _clientversion: ClientVersion) -> Channel { let genres = self.genres.read().map(|value| value.clone()).unwrap_or_default(); let _studios = self.studios.read().map(|value| value.clone()).unwrap_or_default(); let _actresses = self .actresses .read() .map(|value| value.clone()) .unwrap_or_default(); Channel { id: CHANNEL_ID.to_string(), name: "SEXTB".to_string(), description: "JAV archive pages with section, genre, studio, actress, and rich detail metadata." .to_string(), premium: false, favicon: "https://www.google.com/s2/favicons?sz=64&domain=sextb.net".to_string(), status: "active".to_string(), categories: genres.iter().map(|value| value.title.clone()).collect(), options: vec![ ChannelOption { id: "sort".to_string(), title: "Sort".to_string(), description: "Sort a section archive page.".to_string(), systemImage: "list.number".to_string(), colorName: "blue".to_string(), options: vec![ FilterOption { id: "latest".to_string(), title: "Latest".to_string(), }, FilterOption { id: "release".to_string(), title: "New Releases".to_string(), }, FilterOption { id: "liked".to_string(), title: "Most Liked".to_string(), }, FilterOption { id: "viewed".to_string(), title: "Most Viewed".to_string(), }, FilterOption { id: "favorite".to_string(), title: "Most Favorite".to_string(), }, ], multiSelect: false, }, ], nsfw: true, cacheDuration: Some(1800), ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()) } } fn regex(value: &str) -> Result { Regex::new(value).map_err(|error| Error::from(format!("regex `{value}` failed: {error}"))) } fn collapse_whitespace(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } fn normalize_title(text: &str) -> String { Self::collapse_whitespace(text) .trim_matches('"') .trim() .to_string() } fn slugify(value: &str) -> String { let mut output = String::new(); let mut needs_dash = false; for ch in value.chars() { if ch.is_ascii_alphanumeric() { if needs_dash && !output.is_empty() { output.push('-'); } output.push(ch.to_ascii_lowercase()); needs_dash = false; continue; } if matches!(ch, ' ' | '-' | '_' | '/' | '&' | '.' | '+' | '\'') { needs_dash = !output.is_empty(); } } output.trim_matches('-').to_string() } fn parse_abbrev(text: &str) -> Option { let cleaned = text.replace(',', "").trim().to_string(); parse_abbreviated_number(&cleaned) } fn parse_duration(text: &str) -> u32 { text.trim().parse::().unwrap_or(0) * 60 } fn parse_relative_or_date(text: &str) -> Option { let trimmed = text.trim(); if trimmed.is_empty() { return None; } if let Some(value) = Self::parse_absolute_date(trimmed) { return Some(value); } let regex = Regex::new( r"(?i)^(?P\d+)\s+(?Pminute|minutes|hour|hours|day|days|week|weeks|month|months|year|years)\s+ago$", ) .ok()?; let captures = regex.captures(trimmed)?; let amount = captures.name("amount")?.as_str().parse::().ok()?; let unit = captures.name("unit")?.as_str().to_ascii_lowercase(); let now = Utc::now(); let timestamp = match unit.as_str() { "minute" | "minutes" => now - ChronoDuration::minutes(amount), "hour" | "hours" => now - ChronoDuration::hours(amount), "day" | "days" => now - ChronoDuration::days(amount), "week" | "weeks" => now - ChronoDuration::weeks(amount), "month" | "months" => now - ChronoDuration::days(amount * 30), "year" | "years" => now - ChronoDuration::days(amount * 365), _ => return None, }; Some(timestamp.timestamp() as u64) } fn parse_absolute_date(text: &str) -> Option { let normalized = text.replace('.', ""); let date = NaiveDate::parse_from_str(&normalized, "%b %d, %Y").ok()?; let dt = date.and_hms_opt(0, 0, 0)?; Some(DateTime::::from_naive_utc_and_offset(dt, Utc).timestamp() as u64) } fn push_unique(target: &Arc>>, item: FilterOption) { if item.id.is_empty() || item.title.is_empty() { return; } if let Ok(mut values) = target.write() { if !values.iter().any(|existing| { existing.id.eq_ignore_ascii_case(&item.id) || existing.title.eq_ignore_ascii_case(&item.title) }) { values.push(item); } } } /// The site serves plain HTML; fetch it directly. (It used to be read /// through the r.jina.ai markdown mirror, which now answers 403/429.) async fn fetch_html(&self, requester: &mut Requester, url: &str) -> Result { requester .get(url, Some(Version::HTTP_2)) .await .map_err(|error| Error::from(format!("request failed for {url}: {error}"))) } fn pagination_last_page(html: &str) -> Option { let regex = Regex::new(r#"/pg-(?P\d+)['"]"#).ok()?; regex .captures_iter(html) .filter_map(|captures| captures.name("page")?.as_str().parse::().ok()) .max() } fn collect_options(html: &str, pattern: &str) -> Result> { let regex = Self::regex(pattern)?; let mut items = Vec::new(); for captures in regex.captures_iter(html) { let slug = captures .name("slug") .map(|value| value.as_str().trim().to_string()) .unwrap_or_default(); let raw = captures.name("title").map(|value| value.as_str()).unwrap_or_default(); let decoded = htmlentity::entity::decode(raw.as_bytes()) .to_string() .unwrap_or_else(|_| raw.to_string()); let title = Self::normalize_title(decoded.trim_start_matches('#')); if title.is_empty() || slug.is_empty() { continue; } items.push(FilterOption { id: slug, title }); } Ok(items) } fn parse_genre_options(html: &str) -> Result> { Self::collect_options( html, r#"
  • ]*>\s*]*>(?P[^<]+)</a>"#, ) } fn parse_studio_options(html: &str) -> Result<Vec<FilterOption>> { Self::collect_options( html, r#"<div class="tray-item tray-item-list">\s*<a href="/studio/(?P<slug>[^"?]+)"[^>]*>(?:<i[^>]*></i>)?\s*(?P<title>[^<]+)</a>"#, ) } fn parse_actress_options(html: &str) -> Result<Vec<FilterOption>> { Self::collect_options( html, r#"<div class="tray-item-actress">\s*<a href="/actress/(?P<slug>[^"?]+)">.*?<div class="tray-item-actress-title">(?P<title>[^<]+)</div>"#, ) } async fn load_genres(base_url: &str, genres: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> { let provider = Self { url: base_url.to_string(), genres: Arc::clone(&genres), studios: Arc::new(RwLock::new(vec![])), actresses: Arc::new(RwLock::new(vec![])), }; let mut requester = Requester::new(); let html = provider.fetch_html(&mut requester, &format!("{base_url}/genres")).await?; for item in Self::parse_genre_options(&html)? { Self::push_unique(&genres, item); } Ok(()) } async fn load_studios(base_url: &str, studios: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> { let provider = Self { url: base_url.to_string(), genres: Arc::new(RwLock::new(vec![])), studios: Arc::clone(&studios), actresses: Arc::new(RwLock::new(vec![])), }; let mut requester = Requester::new(); let html = provider.fetch_html(&mut requester, &format!("{base_url}/list-studios")).await?; for item in Self::parse_studio_options(&html)? { Self::push_unique(&studios, item); } Ok(()) } async fn load_actresses( base_url: &str, actresses: Arc<RwLock<Vec<FilterOption>>>, ) -> Result<()> { let provider = Self { url: base_url.to_string(), genres: Arc::new(RwLock::new(vec![])), studios: Arc::new(RwLock::new(vec![])), actresses: Arc::clone(&actresses), }; let mut requester = Requester::new(); let first_page = provider.fetch_html(&mut requester, &format!("{base_url}/list-actress")).await?; for item in Self::parse_actress_options(&first_page)? { Self::push_unique(&actresses, item); } let last_page = Self::pagination_last_page(&first_page).unwrap_or(1).min(80); for page in 2..=last_page { let html = provider .fetch_html(&mut requester, &format!("{base_url}/list-actress/pg-{page}")) .await?; for item in Self::parse_actress_options(&html)? { Self::push_unique(&actresses, item); } } Ok(()) } fn resolve_section(options: &ServerOptions) -> String { match options.filter.as_deref().unwrap_or("censored") { "uncensored" => "uncensored".to_string(), "subtitle" => "subtitle".to_string(), "amateur" => "amateur".to_string(), _ => "censored".to_string(), } } fn resolve_sort(sort: &str) -> String { match sort { "release" => "release".to_string(), "liked" => "liked".to_string(), "viewed" | "popular" => "viewed".to_string(), "favorite" => "favorite".to_string(), _ => "latest".to_string(), } } fn find_option_slug(options: &Arc<RwLock<Vec<FilterOption>>>, value: &str) -> Option<String> { let normalized = value.trim().to_ascii_lowercase(); let slug = Self::slugify(&normalized); let options = options.read().ok()?; let option = options.iter().find(|item| { item.id.eq_ignore_ascii_case(value) || item.title.eq_ignore_ascii_case(value) || Self::slugify(&item.title) == slug })?; Some(option.id.clone()) } fn resolve_option_target(&self, options: &ServerOptions, sort: &str) -> Target { if let Some(value) = options.stars.as_deref() { if let Some(slug) = Self::find_option_slug(&self.actresses, value) { return Target::Actress { slug }; } } if let Some(value) = options.sites.as_deref() { if let Some(slug) = Self::find_option_slug(&self.studios, value) { return Target::Studio { slug }; } } if let Some(value) = options.categories.as_deref() { if let Some(slug) = Self::find_option_slug(&self.genres, value) { return Target::Genre { slug }; } } Target::Section { section: Self::resolve_section(options), sort: Self::resolve_sort(sort), } } fn resolve_query_target(&self, query: &str, sort: &str) -> Target { if let Some(slug) = Self::find_option_slug(&self.actresses, query) { return Target::Actress { slug }; } if let Some(slug) = Self::find_option_slug(&self.studios, query) { return Target::Studio { slug }; } if let Some(slug) = Self::find_option_slug(&self.genres, query) { return Target::Genre { slug }; } let slug = Self::slugify(query); if slug.is_empty() { return Target::Section { section: "censored".to_string(), sort: Self::resolve_sort(sort), }; } Target::Search { slug } } fn build_url_for_target(&self, target: &Target, page: u16) -> String { match target { Target::Section { section, sort } => { let mut url = Url::parse(&format!("{}/{}", self.url, section)) .expect("valid section base url"); { let mut pairs = url.query_pairs_mut(); pairs.append_pair("genre", "all"); pairs.append_pair("studio", "all"); pairs.append_pair("quality", "all"); pairs.append_pair("year", "all"); if sort != "latest" { pairs.append_pair("sort", sort); } if page > 1 { pairs.append_pair("pg", &page.to_string()); } } url.to_string() } Target::Search { slug } => { if page > 1 { format!("{}/search/{}/pg-{}", self.url, slug, page) } else { format!("{}/search/{}", self.url, slug) } } Target::Genre { slug } => { if page > 1 { format!("{}/genre/{}/pg-{}", self.url, slug, page) } else { format!("{}/genre/{}", self.url, slug) } } Target::Studio { slug } => { if page > 1 { format!("{}/studio/{}/pg-{}", self.url, slug, page) } else { format!("{}/studio/{}", self.url, slug) } } Target::Actress { slug } => { if page > 1 { format!("{}/actress/{}/pg-{}", self.url, slug, page) } else { format!("{}/actress/{}", self.url, slug) } } } } fn parse_list_items(html: &str) -> Result<Vec<ListItemSeed>> { // A no-hit search still renders a "recommended" tray under the notice. if html.contains("No Video were found") { return Ok(vec![]); } let mut items = Vec::new(); // Cards are `<div class="tray-item">`, optionally with modifiers such // as `tray-item tray-item-uncensored`; `tray-item-title` etc. must not match. let card_start = Self::regex(r#"<div class="tray-item(?: [^"]*)?">"#)?; for card in card_start.split(html).skip(1) { let Some(href) = Self::attr_between(card, "<a href=\"", "\"") else { continue; }; let slug = href.trim_matches('/').to_string(); if slug.is_empty() || slug.contains('/') { continue; } let url = format!("{BASE_URL}/{slug}"); let title_raw = Self::attr_between(card, "class=\"tray-item-title\">", "</div>") .or_else(|| Self::attr_between(card, "alt=\"", "\" ")) .unwrap_or_default(); let title = Self::normalize_title( &htmlentity::entity::decode(title_raw.as_bytes()) .to_string() .unwrap_or_else(|_| title_raw.to_string()), ); if title.is_empty() { continue; } let thumb = Self::attr_between(card, "data-src=\"", "\"") .or_else(|| Self::attr_between(card, "data-poster=\"", "\"")) .unwrap_or_default() .to_string(); if thumb.is_empty() { continue; } let quality = if card.contains("class=\"hd\"") { Some("HD".to_string()) } else if card.contains("class=\"sd\"") { Some("SD".to_string()) } else { None }; let duration = Self::attr_between(card, "class=\"tray-item-runtime\">", "</div>") .map(|value| value.replace("min", "")) .map(|value| Self::parse_duration(&value)) .unwrap_or(0); let code = Self::attr_between(card, "class=\"tray-item-code\">", "</div>") .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let mut tags = Vec::new(); if let Some(value) = quality.clone() { tags.push(value); } if let Some(value) = code.clone() { tags.push(value.clone()); tags.push(value.to_ascii_uppercase()); } tags.sort(); tags.dedup(); items.push(ListItemSeed { id: slug.clone(), title, url, thumb, duration, quality, code, tags, }); } Ok(items) } fn attr_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> { let from = text.find(start)? + start.len(); let rest = &text[from..]; Some(&rest[..rest.find(end)?]) } /// Returns `(title, absolute-url)` for every `<a ...><strong>Title</strong></a>`. fn extract_link_texts(fragment: &str) -> Result<Vec<(String, String)>> { let regex = Self::regex( r#"<a href="(?P<url>[^"]+)"[^>]*>\s*<strong>(?P<title>[^<]+)</strong>\s*</a>"#, )?; Ok(regex .captures_iter(fragment) .filter_map(|captures| { let raw = captures.name("title")?.as_str().trim(); let title = htmlentity::entity::decode(raw.as_bytes()) .to_string() .unwrap_or_else(|_| raw.to_string()); let url = captures.name("url")?.as_str(); let url = if url.starts_with('/') { format!("{BASE_URL}{url}") } else { url.to_string() }; Some((title, url)) }) .collect()) } fn detail_link_slug(url: &str, prefix: &str) -> Option<String> { let path = url.strip_prefix("https://sextb.net/")?; let path = path.strip_prefix(prefix)?; Some(path.trim_start_matches('/').trim_end_matches('/').to_string()) } fn strong_text(fragment: &str) -> String { Self::attr_between(fragment, "<strong>", "</strong>") .map(|value| value.trim().to_string()) .unwrap_or_default() } fn parse_detail(html: &str) -> Result<DetailMetadata> { let mut metadata = DetailMetadata::default(); let tag_regex = Self::regex(r"<[^>]+>")?; // Info rows are `<div class="description"><i ..></i> Key: <strong>..</strong></div>`. for block in html.split("<div class=\"description").skip(1) { let block = block.split("</div>").next().unwrap_or(block); // Drop the remainder of the opening tag (extra classes, `>`). let Some((_, block)) = block.split_once('>') else { continue; }; let Some((key_part, value)) = block.split_once(':') else { continue; }; let key = tag_regex.replace_all(key_part, " ").trim().to_string(); let value = value.trim(); match key.as_str() { "Director" => { metadata.director = Self::extract_link_texts(value)? .first() .map(|(title, _)| title.clone()); } "Label" => { metadata.label = Self::extract_link_texts(value)? .first() .map(|(title, _)| title.clone()); } "Studio" => { if let Some((title, url)) = Self::extract_link_texts(value)?.into_iter().next() { let slug = Self::detail_link_slug(&url, "studio").unwrap_or_else(|| Self::slugify(&title)); metadata.studio = Some((title, slug)); } } "Cast(s)" => { for (title, url) in Self::extract_link_texts(value)? { let slug = Self::detail_link_slug(&url, "actress") .unwrap_or_else(|| Self::slugify(&title)); metadata.cast.push((title, slug)); } } "Genre(s)" => { for (title, url) in Self::extract_link_texts(value)? { let slug = Self::detail_link_slug(&url, "genre") .unwrap_or_else(|| Self::slugify(&title)); metadata.genres.push((title, slug)); } } "Quality" => { let quality = Self::strong_text(value); if !quality.is_empty() { metadata.quality = Some(quality); } } "Release Date" => { metadata.release_date = Self::parse_absolute_date(&Self::strong_text(value)); } "Added" => { metadata.added_at = Self::parse_relative_or_date(&Self::strong_text(value)); } "Viewed" => { let text = tag_regex.replace_all(value, " ").to_string(); metadata.views = Self::parse_abbrev(text.trim()); } _ => {} } } if metadata.views.is_none() { let views_regex = Self::regex(r"(?P<views>[0-9][0-9.,]*[KM]?)\s+Views")?; metadata.views = views_regex .captures(html) .and_then(|captures| captures.name("views")) .and_then(|value| Self::parse_abbrev(value.as_str())); } // Like/dislike counts are filled in client-side, so no rating here. Ok(metadata) } fn parse_player_episodes(html: &str) -> Result<Vec<PlayerEpisode>> { let regex = Self::regex( r#"(?is)<(?:button|a)[^>]*data-source="(?P<film>\d+)"[^>]*data-id="(?P<episode>\d+)"[^>]*>(?P<body>.*?)</(?:button|a)>"#, )?; let body_text_regex = Self::regex(r#"(?is)<[^>]+>"#)?; let mut episodes = regex .captures_iter(html) .filter_map(|captures| { let body = captures.name("body")?.as_str(); let body_text = body_text_regex.replace_all(body, " "); let body_upper = body_text.to_ascii_uppercase(); let label = ["DD", "TB", "SW", "FL", "US", "PP"] .iter() .find(|candidate| body_upper.contains(**candidate)) .map(|candidate| (*candidate).to_string()) .or_else(|| { body_upper .split_whitespace() .find(|token| token.len() >= 2 && token.len() <= 3) .map(|token| token.to_string()) }) .unwrap_or_default(); if label.is_empty() { return None; } let film_id = captures.name("film")?.as_str().trim().to_string(); let episode_id = captures.name("episode")?.as_str().trim().to_string(); Some(PlayerEpisode { label, film_id, episode_id, }) }) .collect::<Vec<_>>(); episodes.sort_by_key(|episode| { if episode.label == "DD" { 0 } else { 1 } }); episodes.dedup(); Ok(episodes) } fn normalize_iframe_url(url: &str) -> Option<String> { let trimmed = url.trim(); if let Some(without) = trimmed.strip_prefix("//") { return Some(format!("https://{without}")); } if trimmed.starts_with("https://") || trimmed.starts_with("http://") { return Some(trimmed.to_string()); } None } fn extract_hoster_url_from_text(text: &str) -> Option<String> { let regex = Regex::new(r#"https?://[^\s"'<>\\]+"#).ok()?; regex.find_iter(text).find_map(|match_value| { let candidate = match_value.as_str().trim(); proxy_name_for_url(candidate).map(|_| candidate.to_string()) }) } fn parse_player_iframe_url(response: &str) -> Option<String> { let iframe_regex = Regex::new(r#"(?is)<iframe[^>]+src=['"](?P<url>(?:https?:)?//[^'"]+)['"]"#).ok()?; let src_json_regex = Regex::new(r#"(?i)"src"\s*:\s*"(?P<url>https?:\\/\\/[^"]+)""#).ok()?; let player_html = serde_json::from_str::<serde_json::Value>(response) .ok() .and_then(|value| value.get("player").and_then(|player| player.as_str()).map(str::to_string)); let from_iframe = player_html .as_deref() .and_then(|html| iframe_regex.captures(html)) .or_else(|| iframe_regex.captures(response)) .and_then(|captures| captures.name("url")) .and_then(|value| Self::normalize_iframe_url(value.as_str())); if from_iframe.is_some() { return from_iframe; } if let Some(url) = src_json_regex .captures(response) .and_then(|captures| captures.name("url")) .map(|value| value.as_str().replace("\\/", "/")) { return Self::normalize_iframe_url(&url); } Self::extract_hoster_url_from_text(response) } async fn resolve_proxy_video_url( &self, seed_url: &str, options: &ServerOptions, ) -> Option<String> { let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_proxy_video_url"); let html = requester.get(seed_url, Some(Version::HTTP_2)).await.ok()?; if let Some(url) = Self::extract_hoster_url_from_text(&html) { return Some(rewrite_hoster_url(options, &url)); } let episodes = Self::parse_player_episodes(&html).ok()?; for episode in episodes { let ajax_url = format!( "{}/ajax/player?episode={}&filmId={}", self.url, episode.episode_id, episode.film_id ); let headers = vec![ ("Referer".to_string(), seed_url.to_string()), ("X-Requested-With".to_string(), "XMLHttpRequest".to_string()), ("Accept".to_string(), "application/json, text/html, */*".to_string()), ]; let Some(response) = requester .get_with_headers(&ajax_url, headers, Some(Version::HTTP_2)) .await .ok() else { continue; }; let iframe_or_hoster = Self::parse_player_iframe_url(&response) .or_else(|| Self::extract_hoster_url_from_text(&response)); let Some(iframe_url) = iframe_or_hoster else { continue; }; if proxy_name_for_url(&iframe_url).is_some() { return Some(rewrite_hoster_url(options, &iframe_url)); } } None } fn apply_detail_metadata(&self, seed: ListItemSeed, detail: DetailMetadata) -> VideoItem { let mut item = VideoItem::new( seed.id, seed.title, seed.url, CHANNEL_ID.to_string(), seed.thumb.clone(), seed.duration, ); item.preview = Some(seed.thumb); item.views = detail.views; item.rating = detail.rating; item.uploadedAt = detail.added_at.or(detail.release_date); if !detail.cast.is_empty() { item.uploader = Some( detail .cast .iter() .map(|(name, _)| name.clone()) .collect::<Vec<_>>() .join(", "), ); if let Some((_, slug)) = detail.cast.first() { item.uploaderUrl = Some(format!("{}/actress/{}", self.url, slug)); } } else if let Some((studio, slug)) = detail.studio.as_ref() { item.uploader = Some(studio.clone()); item.uploaderUrl = Some(format!("{}/studio/{}", self.url, slug)); } let mut tags = seed.tags; if let Some(quality) = seed.quality { tags.push(quality); } if let Some(code) = seed.code { tags.push(code); } for alias in detail.aliases { tags.push(alias); } if let Some(director) = detail.director { tags.push(director); } if let Some(label) = detail.label { tags.push(label); } if let Some((studio, _)) = detail.studio { tags.push(studio); } for (cast, _) in detail.cast { tags.push(cast); } for (genre, _) in detail.genres { tags.push(genre); } if let Some(quality) = detail.quality { tags.push(quality); } tags.retain(|value| !value.trim().is_empty()); tags.sort(); tags.dedup(); if !tags.is_empty() { item.tags = Some(tags); } item } async fn enrich_seed(&self, seed: ListItemSeed, options: &ServerOptions) -> VideoItem { let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_seed"); let detail_fetch = timeout( StdDuration::from_secs(10), self.fetch_html(&mut requester, &seed.url), ) .await; let mut item = match detail_fetch { Ok(Ok(html)) => match Self::parse_detail(&html) { Ok(detail) => self.apply_detail_metadata(seed.clone(), detail), Err(error) => { report_provider_error_background(CHANNEL_ID, "parse_detail", &error.to_string()); self.apply_detail_metadata(seed, DetailMetadata::default()) } }, Ok(Err(error)) => { report_provider_error_background(CHANNEL_ID, "fetch_detail", &error.to_string()); self.apply_detail_metadata(seed, DetailMetadata::default()) } Err(_) => { report_provider_error_background(CHANNEL_ID, "fetch_detail_timeout", &seed.url); self.apply_detail_metadata(seed, DetailMetadata::default()) } }; match timeout( StdDuration::from_secs(10), self.resolve_proxy_video_url(&item.url, options), ) .await { Ok(Some(proxy_url)) => item.url = proxy_url, Ok(None) => {} Err(_) => { report_provider_error_background(CHANNEL_ID, "resolve_proxy_video_url_timeout", &item.url); } } item } async fn fetch_items_for_url( &self, cache: VideoCache, url: String, per_page_limit: usize, options: &ServerOptions, ) -> Result<Vec<VideoItem>> { if let Some((time, items)) = cache.get(&url) { if time.elapsed().unwrap_or_default().as_secs() < 60 * 15 { return Ok(items.clone()); } } let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_items_for_url"); let html = timeout( StdDuration::from_secs(12), self.fetch_html(&mut requester, &url), ) .await .map_err(|_| Error::from(format!("list request timed out for {url}")))??; let seeds = Self::parse_list_items(&html)? .into_iter() .take(per_page_limit.max(1)) .collect::<Vec<_>>(); if seeds.is_empty() { return Ok(vec![]); } let items = stream::iter(seeds.into_iter().map(|seed| { let provider = self.clone(); let options = options.clone(); async move { provider.enrich_seed(seed, &options).await } })) .buffer_unordered(4) .collect::<Vec<_>>() .await; if !items.is_empty() { cache.insert(url, items.clone()); } Ok(items) } async fn get( &self, cache: VideoCache, page: u16, sort: &str, per_page_limit: usize, options: ServerOptions, ) -> Result<Vec<VideoItem>> { let target = self.resolve_option_target(&options, sort); let url = self.build_url_for_target(&target, page); self.fetch_items_for_url(cache, url, per_page_limit, &options) .await } async fn query( &self, cache: VideoCache, page: u16, sort: &str, query: &str, per_page_limit: usize, options: ServerOptions, ) -> Result<Vec<VideoItem>> { let target = self.resolve_query_target(query, sort); let url = self.build_url_for_target(&target, page); self.fetch_items_for_url(cache, url, per_page_limit, &options) .await } } #[async_trait] impl Provider for SextbProvider { async fn get_videos( &self, cache: VideoCache, pool: DbPool, sort: String, query: Option<String>, page: String, per_page: String, options: ServerOptions, ) -> Vec<VideoItem> { let _ = pool; let page = page.parse::<u16>().unwrap_or(1); let per_page_limit = per_page.parse::<usize>().unwrap_or(30); let result = match query { Some(query) if !query.trim().is_empty() => { self.query(cache, page, &sort, &query, per_page_limit, options) .await } _ => self.get(cache, page, &sort, per_page_limit, options).await, }; match result { Ok(videos) => videos, Err(error) => { report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await; vec![] } } } fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> { Some(self.build_channel(clientversion)) } } #[cfg(test)] mod tests { use super::*; fn provider() -> SextbProvider { SextbProvider { url: BASE_URL.to_string(), genres: Arc::new(RwLock::new(vec![FilterOption { id: "big-tits".to_string(), title: "Big Tits".to_string(), }])), studios: Arc::new(RwLock::new(vec![FilterOption { id: "madonna".to_string(), title: "Madonna".to_string(), }])), actresses: Arc::new(RwLock::new(vec![FilterOption { id: "yui-hatano".to_string(), title: "Yui Hatano".to_string(), }])), } } #[test] fn builds_section_page_two_url() { let url = provider().build_url_for_target( &Target::Section { section: "amateur".to_string(), sort: "viewed".to_string(), }, 2, ); assert_eq!( url, "https://sextb.net/amateur?genre=all&studio=all&quality=all&year=all&sort=viewed&pg=2" ); } #[test] fn builds_search_page_two_url() { let url = provider().build_url_for_target( &Target::Search { slug: "yui-hatano".to_string(), }, 2, ); assert_eq!(url, "https://sextb.net/search/yui-hatano/pg-2"); } #[test] fn matches_query_to_filter_targets() { let provider = provider(); match provider.resolve_query_target("Big Tits", "latest") { Target::Genre { slug } => assert_eq!(slug, "big-tits"), other => panic!("unexpected target: {other:?}"), } match provider.resolve_query_target("Madonna", "latest") { Target::Studio { slug } => assert_eq!(slug, "madonna"), other => panic!("unexpected target: {other:?}"), } match provider.resolve_query_target("Yui Hatano", "latest") { Target::Actress { slug } => assert_eq!(slug, "yui-hatano"), other => panic!("unexpected target: {other:?}"), } } #[test] fn parses_detail_metadata_block() { let html = r#" <div class="col-7"> <div class="description"> <i class="fa fa-user"></i> Director: <a href="/director/himurokku" title="Himurokku"><strong>Himurokku</strong></a> <i class="fas fa-heart favorite-director" data-id="1"></i> </div> <div class="description"> <i class="fa fa-tag"></i> Label: <a href="/label/madonna-x1" title="Madonna"><strong>Madonna</strong></a> </div> <div class="description"> <i class="fa fa-camera"></i> Studio: <a href="/studio/madonna-abc123" title="MADONNA"><strong>Madonna</strong></a> </div> <div class="description"> <i class="fa fa-users"></i> Cast(s): <a href="/actress/miyuu-imai" title="Miyuu Imai"><strong>Miyuu Imai</strong></a> <i class="fas fa-heart"></i> </div> <div class="description"> <i class="fa fa-list"></i> Genre(s): <a href="/genre/big-tits-aa11" title="Big Tits"><strong>Big Tits</strong></a>, <a href="/genre/creampie-bb22" title="Creampie"><strong>Creampie</strong></a> </div> <div class="description"><i class="fa fa-film"></i> Quality: <strong>HD</strong></div> <div class="description"><i class="fa fa-calendar"></i> Release Date: <strong>Mar. 24, 2026</strong></div> <div class="description"><i class="fa fa-calendar"></i> Added: <strong>2 days ago</strong></div> <div class="description visible-sm"><i class="fa fa-eye"></i> Viewed: 45.2K</div> </div>"#; let detail = SextbProvider::parse_detail(html).expect("detail should parse"); assert_eq!(detail.director.as_deref(), Some("Himurokku")); assert_eq!(detail.label.as_deref(), Some("Madonna")); assert_eq!( detail.studio, Some(("Madonna".to_string(), "madonna-abc123".to_string())) ); assert_eq!(detail.cast, vec![("Miyuu Imai".to_string(), "miyuu-imai".to_string())]); assert_eq!(detail.genres.len(), 2); assert_eq!(detail.genres[0].1, "big-tits-aa11"); assert_eq!(detail.views, Some(45200)); assert_eq!(detail.quality.as_deref(), Some("HD")); assert!(detail.release_date.is_some()); assert!(detail.added_at.is_some()); } #[test] fn parses_listing_cards() { let html = r#"<div class="tray-content"><div class="tray-item"> <a href="/beb-036"> <img class="tray-item-thumbnail lazy" src="/images/default-cover.jpg" data-src="https://cdn001.imggle.net/webp/poster/a.webp" alt="Alt"> <div class="tray-item-description"> <div class="tray-item-title">Hara & Museum</div> <div class="tray-item-meta-info"> <div class="tray-item-quality"><span class="hd">HD</span></div> <div class="tray-item-runtime">120 min</div> <div class="tray-item-code">BEB-036</div> </div> </div> </a> <div class="tray-item-trailer" data-url="x" data-link="/beb-036" data-poster="https://cdn001.imggle.net/thumb/a.jpg"></div> </div> <div class="tray-item tray-item-uncensored"> <a href="/akdl-234"> <img data-src="https://cdn001.imggle.net/webp/poster/b.webp" alt="B"> <div class="tray-item-title">Second</div><div class="tray-item-runtime">150 min</div><div class="tray-item-code">AKDL-234</div> </a> </div></div>"#; let items = SextbProvider::parse_list_items(html).expect("listing should parse"); assert_eq!(items.len(), 2); assert_eq!(items[0].id, "beb-036"); assert_eq!(items[0].url, "https://sextb.net/beb-036"); assert_eq!(items[0].title, "Hara & Museum"); assert_eq!(items[0].duration, 7200); assert_eq!(items[0].quality.as_deref(), Some("HD")); assert_eq!(items[0].code.as_deref(), Some("BEB-036")); assert_eq!(items[0].thumb, "https://cdn001.imggle.net/webp/poster/a.webp"); assert_eq!(items[1].duration, 9000); } #[test] fn empty_search_result_ignores_recommendation_tray() { let html = r#"<div class="tray-content"><h3 class="notice msg-result">Sorry, No Video were found that matched your search query.</h3> <div class="tray-item"> <a href="/abc-001"> <img data-src="https://x/a.webp"> <div class="tray-item-title">Rec</div></a></div></div>"#; assert!(SextbProvider::parse_list_items(html).unwrap().is_empty()); } #[test] fn parses_filter_option_lists() { let genres = r#"<li><i class="fas fa-folder" style="x"></i> <a href="/genre/30-years-old-7c1gtr1u" title="Genre 30 Years Old">30 Years Old</a> <span>(1138)</span></li>"#; let g = SextbProvider::parse_genre_options(genres).unwrap(); assert_eq!(g[0].id, "30-years-old-7c1gtr1u"); assert_eq!(g[0].title, "30 Years Old"); let studios = r##"<div class="tray-item tray-item-list"> <a href="/studio/only-amateurs-29p3k0ax" title="Label"><i class="fas fa-folder-open"></i> #only amateurs</a> <span class="total">13</span> </div>"##; let s = SextbProvider::parse_studio_options(studios).unwrap(); assert_eq!(s[0].id, "only-amateurs-29p3k0ax"); assert_eq!(s[0].title, "only amateurs"); let actresses = r#"<div class="tray-item-actress"> <a href="/actress/th1bdtq81"> <img alt="Yumi"> <div class="tray-item-actress-description"> <div class="tray-item-actress-title">Yumi Kazama</div> </div></a></div>"#; let a = SextbProvider::parse_actress_options(actresses).unwrap(); assert_eq!(a[0].id, "th1bdtq81"); assert_eq!(a[0].title, "Yumi Kazama"); assert_eq!( SextbProvider::pagination_last_page("<a href='/list-actress/pg-2'>2</a><a href='/list-actress/pg-73'>73</a>"), Some(73) ); } #[test] fn parses_dd_player_buttons_from_detail_html() { let html = r#" <div class="episode-list"> <button class="btn-player episode active" data-source="16900830" data-id="3708319"><i class="fa fa-play-circle"></i> TB</button> <button class="btn-player episode" data-source="16900830" data-id="3708330"><i class="fa fa-play-circle"></i> SW</button> <button class="btn-player episode" data-source="16900830" data-id="3708339"><i class="fa fa-play-circle"></i> DD</button> </div> "#; let episodes = SextbProvider::parse_player_episodes(html).expect("episodes should parse"); assert_eq!( episodes.first(), Some(&PlayerEpisode { label: "DD".to_string(), film_id: "16900830".to_string(), episode_id: "3708339".to_string(), }) ); } #[test] fn parses_dd_player_anchor_layout() { let html = r#" <div class="episode-list"> <a class="episode" data-source="200" data-id="301"><span>SW</span></a> <a class="episode active" data-source="200" data-id="302"><span>DD</span></a> </div> "#; let episodes = SextbProvider::parse_player_episodes(html).expect("episodes should parse"); assert_eq!( episodes.first(), Some(&PlayerEpisode { label: "DD".to_string(), film_id: "200".to_string(), episode_id: "302".to_string(), }) ); } #[test] fn parses_iframe_url_from_ajax_player_response() { let response = r#"{"player":"<iframe src=\"https://trailerhg.xyz/e/ttdc7a6qpskt\" width=\"100%\" height=\"100%\" frameborder=\"0\" allowfullscreen=\"true\"></iframe>"}"#; assert_eq!( SextbProvider::parse_player_iframe_url(response).as_deref(), Some("https://trailerhg.xyz/e/ttdc7a6qpskt") ); } #[test] fn parses_protocol_relative_iframe_url_from_ajax_player_response() { let response = r#"{"player":"<iframe src=\"//turboplayers.xyz/t/69bdfb21cc640\"></iframe>"}"#; assert_eq!( SextbProvider::parse_player_iframe_url(response).as_deref(), Some("https://turboplayers.xyz/t/69bdfb21cc640") ); } }