From 76b9d4e0587c086e080d625b5ea31578c192f113 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 17 Oct 2026 05:48:58 +0000 Subject: [PATCH] xvideos: resolve channel/pornstar names without prefix; channel archives via the JSON video list --- src/providers/xvideos.rs | 104 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 6 deletions(-) diff --git a/src/providers/xvideos.rs b/src/providers/xvideos.rs index 63b565d..442c302 100644 --- a/src/providers/xvideos.rs +++ b/src/providers/xvideos.rs @@ -1,6 +1,8 @@ use crate::DbPool; 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::util::cache::VideoCache; use crate::util::parse_abbreviated_number; @@ -86,6 +88,8 @@ enum Target { Best, Search(String), Archive(String), + /// Channel / pornstar profile, served as JSON by `/channels/{slug}/videos/new/{n}`. + Channel(String), } #[derive(Debug, Clone)] @@ -238,6 +242,9 @@ impl XvideosProvider { 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) => { let base = base_url.trim_end_matches('/'); if page <= 1 { @@ -528,9 +535,8 @@ impl XvideosProvider { let slug = value.replace(' ', "_"); return Target::Archive(format!("{BASE_URL}/c/{slug}")); } - "uploader" | "channel" | "profile" => { - let slug = value.replace(' ', "_").to_ascii_lowercase(); - return Target::Archive(format!("{BASE_URL}/{slug}")); + "uploader" | "channel" | "profile" | "pornstar" | "star" | "model" => { + return Target::Channel(slug_dashed(value)); } _ => {} } @@ -545,6 +551,83 @@ impl XvideosProvider { 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 { + 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> { + 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::().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 { if let Some(cat) = options.categories.as_deref() { if cat != "all" && !cat.is_empty() { @@ -602,7 +685,11 @@ impl XvideosProvider { 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() { cache.insert(cache_key, items.clone()); } @@ -625,10 +712,15 @@ impl Provider for XvideosProvider { let page = page.parse::().unwrap_or(1).max(1); let per_page = per_page.parse::().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()), _ => 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 .fetch_target(cache, target, page, per_page, options)