xvideos: resolve channel/pornstar names without prefix; channel archives via the JSON video list

This commit is contained in:
Simon
2026-10-17 05:48:58 +00:00
parent 59ea0495a6
commit 76b9d4e058

View File

@@ -1,6 +1,8 @@
use crate::DbPool; use crate::DbPool;
use crate::api::ClientVersion; use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default}; use crate::providers::{
Provider, probe_first_archive, report_provider_error, requester_or_default, slug_dashed,
};
use crate::status::*; use crate::status::*;
use crate::util::cache::VideoCache; use crate::util::cache::VideoCache;
use crate::util::parse_abbreviated_number; use crate::util::parse_abbreviated_number;
@@ -86,6 +88,8 @@ enum Target {
Best, Best,
Search(String), Search(String),
Archive(String), Archive(String),
/// Channel / pornstar profile, served as JSON by `/channels/{slug}/videos/new/{n}`.
Channel(String),
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -238,6 +242,9 @@ impl XvideosProvider {
format!("{BASE_URL}/?k={encoded}&p={}", page - 1) format!("{BASE_URL}/?k={encoded}&p={}", page - 1)
} }
} }
Target::Channel(slug) => {
format!("{BASE_URL}/channels/{slug}/videos/new/{}", page.saturating_sub(1))
}
Target::Archive(base_url) => { Target::Archive(base_url) => {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
if page <= 1 { if page <= 1 {
@@ -528,9 +535,8 @@ impl XvideosProvider {
let slug = value.replace(' ', "_"); let slug = value.replace(' ', "_");
return Target::Archive(format!("{BASE_URL}/c/{slug}")); return Target::Archive(format!("{BASE_URL}/c/{slug}"));
} }
"uploader" | "channel" | "profile" => { "uploader" | "channel" | "profile" | "pornstar" | "star" | "model" => {
let slug = value.replace(' ', "_").to_ascii_lowercase(); return Target::Channel(slug_dashed(value));
return Target::Archive(format!("{BASE_URL}/{slug}"));
} }
_ => {} _ => {}
} }
@@ -545,6 +551,83 @@ impl XvideosProvider {
Target::Search(trimmed.to_string()) Target::Search(trimmed.to_string())
} }
/// A free-form query that names a channel / pornstar profile goes to its
/// archive (probed on the site; unknown names land on an empty profile),
/// else keyword search.
async fn resolve_keyword(query: &str, options: &ServerOptions) -> Option<Target> {
let slug = slug_dashed(query);
if slug.is_empty() {
return None;
}
let target = Target::Channel(slug);
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
probe_first_archive(
&mut requester,
&[Self::target_url(&target, 1)],
&format!("{BASE_URL}/"),
|body| body.contains("\"videos\":[{"),
)
.await
.map(|_| target)
}
/// Parses the JSON served for a channel / profile video list.
fn parse_channel_json(json: &str, limit: usize) -> Result<Vec<VideoItem>> {
let root: serde_json::Value = serde_json::from_str(json)
.map_err(|e| Error::from(format!("channel json: {e}")))?;
let Some(videos) = root.get("videos").and_then(|v| v.as_array()) else {
return Ok(vec![]);
};
let text = |v: &serde_json::Value, key: &str| -> String {
v.get(key).and_then(|x| x.as_str()).unwrap_or_default().trim().to_string()
};
let mut items = Vec::new();
for video in videos {
let eid = text(video, "eid");
let thumb = text(video, "i");
let title = {
let full = text(video, "tf");
if full.is_empty() { text(video, "t") } else { full }
};
if eid.is_empty() || thumb.is_empty() || title.is_empty() {
continue;
}
let id = video
.get("id")
.map(|x| x.to_string().trim_matches('"').to_string())
.unwrap_or_else(|| eid.clone());
let mut item = VideoItem::new(
id,
Self::decode_html(&title),
format!("{BASE_URL}/video.{eid}/x"),
CHANNEL_ID.to_string(),
thumb,
Self::parse_duration(&text(video, "d")),
);
item.views = Self::parse_views(&text(video, "n"));
item.rating = text(video, "r").trim_end_matches('%').parse::<f32>().ok();
let preview = text(video, "ipu");
if !preview.is_empty() {
item.preview = Some(preview);
}
let name = text(video, "pn");
let slug = text(video, "pu");
if !name.is_empty() {
item.uploader = Some(name);
let slug = slug.trim_matches('/').to_string();
if !slug.is_empty() {
item.uploaderUrl = Some(format!("{BASE_URL}/{slug}"));
item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
}
}
items.push(item);
if items.len() >= limit {
break;
}
}
Ok(items)
}
fn resolve_option_target(options: &ServerOptions, sort: &str) -> Target { fn resolve_option_target(options: &ServerOptions, sort: &str) -> Target {
if let Some(cat) = options.categories.as_deref() { if let Some(cat) = options.categories.as_deref() {
if cat != "all" && !cat.is_empty() { if cat != "all" && !cat.is_empty() {
@@ -602,7 +685,11 @@ impl XvideosProvider {
return Ok(vec![]); return Ok(vec![]);
} }
let items = Self::parse_listing(&html, per_page)?; let items = if matches!(target, Target::Channel(_)) {
Self::parse_channel_json(&html, per_page)?
} else {
Self::parse_listing(&html, per_page)?
};
if !items.is_empty() { if !items.is_empty() {
cache.insert(cache_key, items.clone()); cache.insert(cache_key, items.clone());
} }
@@ -625,10 +712,15 @@ impl Provider for XvideosProvider {
let page = page.parse::<u16>().unwrap_or(1).max(1); let page = page.parse::<u16>().unwrap_or(1).max(1);
let per_page = per_page.parse::<usize>().unwrap_or(10).clamp(1, 60); let per_page = per_page.parse::<usize>().unwrap_or(10).clamp(1, 60);
let target = match query { let mut target = match query {
Some(q) if !q.trim().is_empty() => Self::resolve_query_target(q.trim()), Some(q) if !q.trim().is_empty() => Self::resolve_query_target(q.trim()),
_ => Self::resolve_option_target(&options, &sort), _ => Self::resolve_option_target(&options, &sort),
}; };
if let Target::Search(query) = &target {
if let Some(found) = Self::resolve_keyword(query, &options).await {
target = found;
}
}
match self match self
.fetch_target(cache, target, page, per_page, options) .fetch_target(cache, target, page, per_page, options)