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::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<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 {
|
||||
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::<u16>().unwrap_or(1).max(1);
|
||||
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()),
|
||||
_ => 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)
|
||||
|
||||
Reference in New Issue
Block a user