Files
hottub/src/providers/sextb.rs
Simon 39314c9a53 Fix sextb: fetch HTML directly instead of the dead Jina markdown mirror
r.jina.ai now answers 403/429, so every request returned nothing. The
site serves plain HTML to the emulating client; parse cards, detail rows
and option lists from HTML, and return empty for no-hit searches (the
site renders a recommendation tray under the notice).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 07:21:50 +00:00

1343 lines
50 KiB
Rust

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<RwLock<Vec<FilterOption>>>,
studios: Arc<RwLock<Vec<FilterOption>>>,
actresses: Arc<RwLock<Vec<FilterOption>>>,
}
#[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<String>,
code: Option<String>,
tags: Vec<String>,
}
#[derive(Debug, Clone, Default)]
struct DetailMetadata {
aliases: Vec<String>,
director: Option<String>,
label: Option<String>,
studio: Option<(String, String)>,
cast: Vec<(String, String)>,
genres: Vec<(String, String)>,
quality: Option<String>,
release_date: Option<u64>,
added_at: Option<u64>,
views: Option<u32>,
rating: Option<f32>,
}
#[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> {
Regex::new(value).map_err(|error| Error::from(format!("regex `{value}` failed: {error}")))
}
fn collapse_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().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<u32> {
let cleaned = text.replace(',', "").trim().to_string();
parse_abbreviated_number(&cleaned)
}
fn parse_duration(text: &str) -> u32 {
text.trim().parse::<u32>().unwrap_or(0) * 60
}
fn parse_relative_or_date(text: &str) -> Option<u64> {
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<amount>\d+)\s+(?P<unit>minute|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::<i64>().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<u64> {
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::<Utc>::from_naive_utc_and_offset(dt, Utc).timestamp() as u64)
}
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, 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<String> {
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<u16> {
let regex = Regex::new(r#"/pg-(?P<page>\d+)['"]"#).ok()?;
regex
.captures_iter(html)
.filter_map(|captures| captures.name("page")?.as_str().parse::<u16>().ok())
.max()
}
fn collect_options(html: &str, pattern: &str) -> Result<Vec<FilterOption>> {
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<Vec<FilterOption>> {
Self::collect_options(
html,
r#"<li><i class="fas fa-folder"[^>]*></i>\s*<a href="/genre/(?P<slug>[^"?]+)"[^>]*>(?P<title>[^<]+)</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 &amp; 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")
);
}
}