xvideos: resolve channel/pornstar names without prefix; channel archives via the JSON video list
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user