Add fullporner provider with xiaoshenke proxy
- Implement fullporner.com provider with search, pagination, categories, pornstars - Add xiaoshenke.net proxy for Cloudflare-protected embed videos - Proxy fetches signed URLs via FlareSolverr and streams video content - Fix quality bitmask mapping (1=360p, 2=480p, 4=720p, 8=1080p) - Support HEAD/GET/Range for video streaming Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ cbc = { version = "0.1", features = ["alloc"] }
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
chromiumoxide = { version = "0.7", features = ["tokio-runtime"] }
|
chromiumoxide = { version = "0.7", features = ["tokio-runtime"] }
|
||||||
playwright-rs = "0.17"
|
playwright-rs = "0.17"
|
||||||
|
http-body-util = "0.1.5"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
warnings = "warn"
|
warnings = "warn"
|
||||||
@@ -68,4 +69,4 @@ incremental = true
|
|||||||
opt-level = 3
|
opt-level = 3
|
||||||
codegen-units = 16
|
codegen-units = 16
|
||||||
lto = false
|
lto = false
|
||||||
debug = 0
|
debug = 0
|
||||||
|
|||||||
5
build.rs
5
build.rs
@@ -361,6 +361,11 @@ const PROVIDERS: &[ProviderDef] = &[
|
|||||||
module: "fullporner",
|
module: "fullporner",
|
||||||
ty: "FullpornerProvider",
|
ty: "FullpornerProvider",
|
||||||
},
|
},
|
||||||
|
ProviderDef {
|
||||||
|
id: "fullpornerorg",
|
||||||
|
module: "fullpornerorg",
|
||||||
|
ty: "FullpornerorgProvider",
|
||||||
|
},
|
||||||
ProviderDef {
|
ProviderDef {
|
||||||
id: "thepornbunny",
|
id: "thepornbunny",
|
||||||
module: "thepornbunny",
|
module: "thepornbunny",
|
||||||
|
|||||||
@@ -16,11 +16,8 @@ pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const BASE_URL: &str = "https://fullporner.com";
|
const BASE_URL: &str = "https://fullporner.com";
|
||||||
const MEDIA_HOST: &str = "https://xiaoshenke.net";
|
|
||||||
const CHANNEL_ID: &str = "fullporner";
|
const CHANNEL_ID: &str = "fullporner";
|
||||||
const DEFAULT_PER_PAGE: usize = 32;
|
const DEFAULT_PER_PAGE: usize = 32;
|
||||||
const BROWSER_UA: &str =
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0";
|
|
||||||
|
|
||||||
error_chain! {
|
error_chain! {
|
||||||
foreign_links {
|
foreign_links {
|
||||||
@@ -262,17 +259,68 @@ impl FullpornerProvider {
|
|||||||
Ok(stubs)
|
Ok(stubs)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_format(thumb_id: &str) -> VideoFormat {
|
/// Extract xiaoshenke embed URL from the watch page.
|
||||||
let url = format!("{MEDIA_HOST}/vid/{thumb_id}/720");
|
/// Returns (video_id, quality_bitmask) from iframe src like "//xiaoshenke.net/video/3827433/14"
|
||||||
let mut fmt = VideoFormat::new(url, "720p".to_string(), "mp4".to_string());
|
fn extract_embed_params(watch_html: &str) -> Option<(String, u8)> {
|
||||||
fmt.add_http_header("Referer".to_string(), format!("{MEDIA_HOST}/"));
|
let document = Html::parse_document(watch_html);
|
||||||
fmt.add_http_header("User-Agent".to_string(), BROWSER_UA.to_string());
|
let iframe_sel = Selector::parse("iframe[src*=\"xiaoshenke.net/video/\"]").ok()?;
|
||||||
fmt
|
|
||||||
|
for iframe in document.select(&iframe_sel) {
|
||||||
|
if let Some(src) = iframe.value().attr("src") {
|
||||||
|
// src format: //xiaoshenke.net/video/{id}/{quality_bitmask}
|
||||||
|
let parts: Vec<&str> = src.split('/').collect();
|
||||||
|
if parts.len() >= 5 && parts[2] == "xiaoshenke.net" && parts[3] == "video" {
|
||||||
|
let video_id = parts[4].to_string();
|
||||||
|
let quality_bitmask = parts[5].parse::<u8>().ok()?;
|
||||||
|
return Some((video_id, quality_bitmask));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch watch page and extract embed params, then build proxy URL for formats.
|
||||||
|
async fn enrich_with_formats(
|
||||||
|
mut item: VideoItem,
|
||||||
|
options: &ServerOptions,
|
||||||
|
) -> VideoItem {
|
||||||
|
let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_with_formats");
|
||||||
|
let html = match requester.get(&item.url, None).await {
|
||||||
|
Ok(html) => html,
|
||||||
|
Err(_) => return item, // Return item without formats on fetch failure
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((video_id, quality_bitmask)) = Self::extract_embed_params(&html) {
|
||||||
|
let proxy_url = format!(
|
||||||
|
"http://127.0.0.1:18080/proxy/xiaoshenke/video/{}/{}",
|
||||||
|
video_id, quality_bitmask
|
||||||
|
);
|
||||||
|
let height = Self::quality_bitmask_to_height(quality_bitmask);
|
||||||
|
item.formats = Some(vec![VideoFormat::new(
|
||||||
|
proxy_url,
|
||||||
|
format!("{}p", height),
|
||||||
|
"mp4".to_string(),
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quality_bitmask_to_height(bitmask: u8) -> u32 {
|
||||||
|
if bitmask & 8 != 0 {
|
||||||
|
1080
|
||||||
|
} else if bitmask & 4 != 0 {
|
||||||
|
720
|
||||||
|
} else if bitmask & 2 != 0 {
|
||||||
|
480
|
||||||
|
} else if bitmask & 1 != 0 {
|
||||||
|
360
|
||||||
|
} else {
|
||||||
|
720
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stub_to_item(stub: CardStub) -> VideoItem {
|
fn stub_to_item(stub: CardStub) -> VideoItem {
|
||||||
let url = format!("{BASE_URL}/watch/{}", stub.slug);
|
let url = format!("{BASE_URL}/watch/{}", stub.slug);
|
||||||
let formats = stub.thumb_id.as_deref().map(|id| vec![Self::build_format(id)]);
|
|
||||||
|
|
||||||
let mut item = VideoItem::new(
|
let mut item = VideoItem::new(
|
||||||
stub.slug,
|
stub.slug,
|
||||||
@@ -282,7 +330,6 @@ impl FullpornerProvider {
|
|||||||
stub.thumb,
|
stub.thumb,
|
||||||
stub.duration,
|
stub.duration,
|
||||||
);
|
);
|
||||||
item.formats = formats;
|
|
||||||
item.uploadedAt = stub.uploaded_at;
|
item.uploadedAt = stub.uploaded_at;
|
||||||
item.aspectRatio = Some(16.0 / 9.0);
|
item.aspectRatio = Some(16.0 / 9.0);
|
||||||
item
|
item
|
||||||
@@ -301,11 +348,24 @@ impl FullpornerProvider {
|
|||||||
.map_err(|e| Error::from(format!("fetch failed for {url}: {e}")))?;
|
.map_err(|e| Error::from(format!("fetch failed for {url}: {e}")))?;
|
||||||
|
|
||||||
let stubs = Self::parse_listing_page(&html)?;
|
let stubs = Self::parse_listing_page(&html)?;
|
||||||
let items = stubs
|
let mut items = Vec::new();
|
||||||
.into_iter()
|
|
||||||
.take(per_page)
|
for stub in stubs {
|
||||||
.map(Self::stub_to_item)
|
// Only include items that have a valid thumb_id (i.e., playable formats)
|
||||||
.collect();
|
// Old thumbnail format (/imgs/.../xxx_main.jpg) doesn't yield a valid video ID
|
||||||
|
if stub.thumb_id.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = Self::stub_to_item(stub);
|
||||||
|
let item = Self::enrich_with_formats(item, options).await;
|
||||||
|
items.push(item);
|
||||||
|
|
||||||
|
if items.len() >= per_page {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
542
src/providers/fullpornerorg.rs
Normal file
542
src/providers/fullpornerorg.rs
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
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::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use error_chain::error_chain;
|
||||||
|
use futures::stream::{self, StreamExt};
|
||||||
|
use regex::Regex;
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
|
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||||
|
crate::providers::ProviderChannelMetadata {
|
||||||
|
group_id: "mainstream-tube",
|
||||||
|
tags: &["tube", "full-length", "embed", "lulustream"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://fullporner.org";
|
||||||
|
const CHANNEL_ID: &str = "fullpornerorg";
|
||||||
|
const DEFAULT_PER_PAGE: usize = 24;
|
||||||
|
const ENRICH_CONCURRENCY: usize = 4;
|
||||||
|
|
||||||
|
const BROWSER_UA: &str =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
|
||||||
|
Chrome/124.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
error_chain! {
|
||||||
|
foreign_links {
|
||||||
|
Io(std::io::Error);
|
||||||
|
HttpRequest(wreq::Error);
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
Parse(msg: String) {
|
||||||
|
description("parse error")
|
||||||
|
display("parse error: {}", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FullpornerorgProvider;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Target {
|
||||||
|
Latest,
|
||||||
|
Search { query: String },
|
||||||
|
Pornstar { slug: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CardStub {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
thumb: String,
|
||||||
|
duration: u32,
|
||||||
|
actor_slugs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FullpornerorgProvider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||||
|
Channel {
|
||||||
|
id: CHANNEL_ID.to_string(),
|
||||||
|
name: "FullPorner.org".to_string(),
|
||||||
|
description:
|
||||||
|
"Full length porn videos from fullporner.org. Uses lulustream embeds; \
|
||||||
|
HLS URLs extracted via the lulustream proxy."
|
||||||
|
.to_string(),
|
||||||
|
premium: false,
|
||||||
|
favicon: "https://www.google.com/s2/favicons?sz=64&domain=fullporner.org".to_string(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
categories: vec![],
|
||||||
|
options: vec![
|
||||||
|
ChannelOption {
|
||||||
|
id: "sort".to_string(),
|
||||||
|
title: "Sort".to_string(),
|
||||||
|
description: "Browse FullPorner.org by newest videos.".to_string(),
|
||||||
|
systemImage: "arrow.up.arrow.down".to_string(),
|
||||||
|
colorName: "blue".to_string(),
|
||||||
|
options: vec![
|
||||||
|
FilterOption {
|
||||||
|
id: "new".to_string(),
|
||||||
|
title: "Latest".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nsfw: true,
|
||||||
|
cacheDuration: Some(1800),
|
||||||
|
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selector(value: &str) -> Result<Selector> {
|
||||||
|
Selector::parse(value)
|
||||||
|
.map_err(|error| Error::from(format!("selector `{value}` failed: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_target(query: Option<&str>) -> Target {
|
||||||
|
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
|
||||||
|
if let Some(slug) = q.strip_prefix("pornstar:") {
|
||||||
|
return Target::Pornstar {
|
||||||
|
slug: slug.trim().to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(slug) = q.strip_prefix("star:") {
|
||||||
|
return Target::Pornstar {
|
||||||
|
slug: slug.trim().to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Target::Search {
|
||||||
|
query: q.to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Target::Latest
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_listing_url(target: &Target, page: u16) -> String {
|
||||||
|
let page = page.max(1);
|
||||||
|
match target {
|
||||||
|
Target::Latest => {
|
||||||
|
if page <= 1 {
|
||||||
|
format!("{BASE_URL}/")
|
||||||
|
} else {
|
||||||
|
format!("{BASE_URL}/page/{page}/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Search { query } => {
|
||||||
|
let encoded: String =
|
||||||
|
url::form_urlencoded::byte_serialize(query.as_bytes()).collect();
|
||||||
|
if page <= 1 {
|
||||||
|
format!("{BASE_URL}/?s={encoded}")
|
||||||
|
} else {
|
||||||
|
format!("{BASE_URL}/page/{page}/?s={encoded}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Pornstar { slug } => {
|
||||||
|
if page <= 1 {
|
||||||
|
format!("{BASE_URL}/videos/pornstar/{slug}/")
|
||||||
|
} else {
|
||||||
|
format!("{BASE_URL}/videos/pornstar/{slug}/page/{page}/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_headers(referer: &str) -> Vec<(String, String)> {
|
||||||
|
vec![
|
||||||
|
("User-Agent".to_string(), BROWSER_UA.to_string()),
|
||||||
|
("Referer".to_string(), referer.to_string()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_html(options: &ServerOptions, url: &str, referer: &str) -> Result<String> {
|
||||||
|
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_html");
|
||||||
|
requester
|
||||||
|
.get_with_headers(url, Self::html_headers(referer), None)
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_listing_page(html: &str) -> Result<Vec<CardStub>> {
|
||||||
|
let document = Html::parse_document(html);
|
||||||
|
// Video cards are article elements with data-video-id attribute
|
||||||
|
let card_sel = Self::selector("article.loop-video[data-video-id]")?;
|
||||||
|
let link_sel = Self::selector("a[href]")?;
|
||||||
|
let thumb_attr_sel = Self::selector("[data-main-thumb]")?;
|
||||||
|
let title_sel = Self::selector("header.entry-header h2, header.entry-header .entry-title a")?;
|
||||||
|
let duration_sel = Self::selector(".duration")?;
|
||||||
|
let actor_regex = Regex::new(r"\bactors-([a-z0-9-]+)")
|
||||||
|
.map_err(|error| Error::from(format!("regex failed: {error}")))?;
|
||||||
|
|
||||||
|
let mut stubs = Vec::new();
|
||||||
|
let mut seen_ids = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for card in document.select(&card_sel) {
|
||||||
|
let class_attr = card.value().attr("class").unwrap_or_default();
|
||||||
|
|
||||||
|
let id = class_attr
|
||||||
|
.split_whitespace()
|
||||||
|
.find_map(|c| c.strip_prefix("post-"))
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| {
|
||||||
|
card.value()
|
||||||
|
.attr("data-post-id")
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if id.is_empty() || !seen_ids.insert(id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = card
|
||||||
|
.select(&link_sel)
|
||||||
|
.next()
|
||||||
|
.and_then(|a| a.value().attr("href"))
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if url.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = card
|
||||||
|
.select(&title_sel)
|
||||||
|
.next()
|
||||||
|
.map(|s| s.text().collect::<Vec<_>>().join(""))
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.unwrap_or_else(|| id.clone());
|
||||||
|
|
||||||
|
let thumb = card
|
||||||
|
.select(&thumb_attr_sel)
|
||||||
|
.next()
|
||||||
|
.and_then(|el| el.value().attr("data-main-thumb"))
|
||||||
|
.map(str::to_string)
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
card.select(&Self::selector("img[src]").ok()?)
|
||||||
|
.next()
|
||||||
|
.and_then(|img| img.value().attr("src"))
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let duration_text = card
|
||||||
|
.select(&duration_sel)
|
||||||
|
.next()
|
||||||
|
.map(|s| s.text().collect::<Vec<_>>().join(""))
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let duration = crate::util::time::parse_time_to_seconds(&duration_text)
|
||||||
|
.and_then(|s| u32::try_from(s).ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Extract actor slugs from class names like "actors-vivienne-vo"
|
||||||
|
let actor_slugs: Vec<String> = actor_regex
|
||||||
|
.captures_iter(class_attr)
|
||||||
|
.filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
stubs.push(CardStub {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
thumb,
|
||||||
|
duration,
|
||||||
|
actor_slugs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stubs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the luluvdo.com embed URL from a fullporner.org detail page.
|
||||||
|
fn extract_embed_url(html: &str) -> Option<String> {
|
||||||
|
// The embed is in an iframe: <iframe src="https://luluvdo.com/e/VIDEO_ID" ...>
|
||||||
|
let needle = "luluvdo.com/e/";
|
||||||
|
let needle_idx = html.find(needle)?;
|
||||||
|
// Walk back to the start of the URL (find `https://` or `http://`).
|
||||||
|
let head = &html[..=needle_idx];
|
||||||
|
let scheme_idx = head
|
||||||
|
.rfind("https://")
|
||||||
|
.or_else(|| head.rfind("http://"))?;
|
||||||
|
let after_scheme = &html[scheme_idx..];
|
||||||
|
// End of the URL: the next quote, space, or `>`.
|
||||||
|
let end = after_scheme
|
||||||
|
.find(|c: char| c == '"' || c == '\'' || c == ' ' || c == '>')
|
||||||
|
.unwrap_or(after_scheme.len());
|
||||||
|
Some(after_scheme[..end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the embed ID from a `https://luluvdo.com/e/{id}` or `https://luluvid.com/e/{id}` URL.
|
||||||
|
fn extract_embed_id(embed_url: &str) -> Option<String> {
|
||||||
|
let prefixes = ["luluvdo.com/e/", "luluvid.com/e/", "lulustream.com/e/"];
|
||||||
|
for prefix in prefixes {
|
||||||
|
if let Some(idx) = embed_url.find(prefix) {
|
||||||
|
let after = &embed_url[idx + prefix.len()..];
|
||||||
|
let end = after
|
||||||
|
.find(|c: char| c == '?' || c == '/' || c == '"' || c == '\'')
|
||||||
|
.unwrap_or(after.len());
|
||||||
|
if end > 0 {
|
||||||
|
return Some(after[..end].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the lulustream proxy URL for a given embed ID.
|
||||||
|
fn build_proxy_url(embed_id: &str, options: &ServerOptions) -> String {
|
||||||
|
crate::providers::build_proxy_url(options, "lulustream", embed_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enrich_card(stub: CardStub, options: &ServerOptions) -> Result<VideoItem> {
|
||||||
|
// Step 1: fetch the detail page to find the luluvdo embed.
|
||||||
|
let detail_html = Self::fetch_html(options, &stub.url, BASE_URL).await?;
|
||||||
|
let embed_url = Self::extract_embed_url(&detail_html)
|
||||||
|
.ok_or_else(|| Error::from(format!("no luluvdo embed in {}", stub.url)))?;
|
||||||
|
let embed_id = Self::extract_embed_id(&embed_url)
|
||||||
|
.ok_or_else(|| Error::from(format!("could not parse embed id from {embed_url}")))?;
|
||||||
|
|
||||||
|
// Step 2: build the proxy URL (will hit /proxy/lulustream/{embed_id} which
|
||||||
|
// resolves the actual HLS URL via the lulustream proxy).
|
||||||
|
let proxy_url = Self::build_proxy_url(&embed_id, options);
|
||||||
|
|
||||||
|
// Build the format. The proxy will return a redirect to the actual HLS URL.
|
||||||
|
let mut format = VideoFormat::m3u8(proxy_url, "auto".to_string(), "m3u8".to_string());
|
||||||
|
format.add_http_header("Referer".to_string(), BASE_URL.to_string());
|
||||||
|
format.add_http_header("User-Agent".to_string(), BROWSER_UA.to_string());
|
||||||
|
|
||||||
|
let mut item = VideoItem::new(
|
||||||
|
stub.id,
|
||||||
|
stub.title,
|
||||||
|
stub.url,
|
||||||
|
CHANNEL_ID.to_string(),
|
||||||
|
stub.thumb,
|
||||||
|
stub.duration,
|
||||||
|
);
|
||||||
|
item.formats = Some(vec![format]);
|
||||||
|
if !stub.actor_slugs.is_empty() {
|
||||||
|
item.tags = Some(stub.actor_slugs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_page(
|
||||||
|
target: Target,
|
||||||
|
page: u16,
|
||||||
|
per_page: usize,
|
||||||
|
options: &ServerOptions,
|
||||||
|
) -> Result<Vec<VideoItem>> {
|
||||||
|
let url = Self::build_listing_url(&target, page);
|
||||||
|
let html = Self::fetch_html(options, &url, BASE_URL).await?;
|
||||||
|
let stubs = Self::parse_listing_page(&html)?;
|
||||||
|
if stubs.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let limited: Vec<_> = stubs.into_iter().take(per_page).collect();
|
||||||
|
let options = options.clone();
|
||||||
|
|
||||||
|
let items = stream::iter(limited.into_iter().map(|stub| {
|
||||||
|
let options = options.clone();
|
||||||
|
async move {
|
||||||
|
match Self::enrich_card(stub, &options).await {
|
||||||
|
Ok(item) => Some(item),
|
||||||
|
Err(error) => {
|
||||||
|
report_provider_error_background(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"fetch_page.enrich_card",
|
||||||
|
&error.to_string(),
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.buffer_unordered(ENRICH_CONCURRENCY)
|
||||||
|
.filter_map(async move |v| v)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for FullpornerorgProvider {
|
||||||
|
async fn get_videos(
|
||||||
|
&self,
|
||||||
|
_cache: crate::util::cache::VideoCache,
|
||||||
|
_pool: DbPool,
|
||||||
|
_sort: String,
|
||||||
|
query: Option<String>,
|
||||||
|
page: String,
|
||||||
|
per_page: String,
|
||||||
|
options: ServerOptions,
|
||||||
|
) -> Vec<VideoItem> {
|
||||||
|
let page = page.parse::<u16>().unwrap_or(1).max(1);
|
||||||
|
let per_page = per_page
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap_or(DEFAULT_PER_PAGE)
|
||||||
|
.clamp(1, 48);
|
||||||
|
|
||||||
|
let normalized_query = query
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|q| !q.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
|
||||||
|
let target = Self::pick_target(normalized_query.as_deref());
|
||||||
|
|
||||||
|
match Self::fetch_page(target, page, per_page, &options).await {
|
||||||
|
Ok(items) => items,
|
||||||
|
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::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_listing_urls() {
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::build_listing_url(&Target::Latest, 1),
|
||||||
|
"https://fullporner.org/"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::build_listing_url(&Target::Latest, 2),
|
||||||
|
"https://fullporner.org/page/2/"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::build_listing_url(
|
||||||
|
&Target::Search {
|
||||||
|
query: "milf".to_string()
|
||||||
|
},
|
||||||
|
1
|
||||||
|
),
|
||||||
|
"https://fullporner.org/?s=milf"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::build_listing_url(
|
||||||
|
&Target::Search {
|
||||||
|
query: "teen".to_string()
|
||||||
|
},
|
||||||
|
2
|
||||||
|
),
|
||||||
|
"https://fullporner.org/page/2/?s=teen"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::build_listing_url(
|
||||||
|
&Target::Pornstar {
|
||||||
|
slug: "vivienne-vo".to_string()
|
||||||
|
},
|
||||||
|
1
|
||||||
|
),
|
||||||
|
"https://fullporner.org/videos/pornstar/vivienne-vo/"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::build_listing_url(
|
||||||
|
&Target::Pornstar {
|
||||||
|
slug: "vivienne-vo".to_string()
|
||||||
|
},
|
||||||
|
2
|
||||||
|
),
|
||||||
|
"https://fullporner.org/videos/pornstar/vivienne-vo/page/2/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_search_for_query() {
|
||||||
|
match FullpornerorgProvider::pick_target(Some("test")) {
|
||||||
|
Target::Search { query } => assert_eq!(query, "test"),
|
||||||
|
other => panic!("expected Search, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_latest_for_no_query() {
|
||||||
|
assert!(matches!(
|
||||||
|
FullpornerorgProvider::pick_target(None),
|
||||||
|
Target::Latest
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
FullpornerorgProvider::pick_target(Some("")),
|
||||||
|
Target::Latest
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
FullpornerorgProvider::pick_target(Some(" ")),
|
||||||
|
Target::Latest
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_pornstar_for_prefix() {
|
||||||
|
match FullpornerorgProvider::pick_target(Some("pornstar:vivienne-vo")) {
|
||||||
|
Target::Pornstar { slug, .. } => assert_eq!(slug, "vivienne-vo"),
|
||||||
|
other => panic!("expected Pornstar, got {:?}", other),
|
||||||
|
}
|
||||||
|
match FullpornerorgProvider::pick_target(Some("star:vivienne-vo")) {
|
||||||
|
Target::Pornstar { slug, .. } => assert_eq!(slug, "vivienne-vo"),
|
||||||
|
other => panic!("expected Pornstar, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_embed_id_from_luluvdo_url() {
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::extract_embed_id("https://luluvdo.com/e/a0jb2i5304d4"),
|
||||||
|
Some("a0jb2i5304d4".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::extract_embed_id("https://luluvid.com/e/a0jb2i5304d4"),
|
||||||
|
Some("a0jb2i5304d4".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::extract_embed_id("https://lulustream.com/e/a0jb2i5304d4"),
|
||||||
|
Some("a0jb2i5304d4".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::extract_embed_id("https://luluvdo.com/e/a0jb2i5304d4?foo=bar"),
|
||||||
|
Some("a0jb2i5304d4".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::extract_embed_id("https://other.com/e/abc"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_embed_url_from_detail_html() {
|
||||||
|
let html = r#"<iframe src="https://luluvdo.com/e/a0jb2i5304d4" scrolling="no" frameborder="0"></iframe>"#;
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::extract_embed_url(html).as_deref(),
|
||||||
|
Some("https://luluvdo.com/e/a0jb2i5304d4")
|
||||||
|
);
|
||||||
|
// Also test uppercase attributes
|
||||||
|
let html2 = r#"<IFRAME SRC="https://luluvdo.com/e/abc123" FRAMEBORDER=0>"#;
|
||||||
|
assert_eq!(
|
||||||
|
FullpornerorgProvider::extract_embed_url(html2).as_deref(),
|
||||||
|
Some("https://luluvdo.com/e/abc123")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -187,7 +187,7 @@ async fn validate_media_response(
|
|||||||
|
|
||||||
let mut requester = requester;
|
let mut requester = requester;
|
||||||
let response = requester
|
let response = requester
|
||||||
.get_raw_with_headers_timeout(url, headers, Some(VALIDATION_MEDIA_TIMEOUT))
|
.get_raw_with_headers_timeout(url, headers, Some(VALIDATION_MEDIA_TIMEOUT), None)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
format!(
|
format!(
|
||||||
@@ -1231,7 +1231,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut requester = Requester::new();
|
let mut requester = Requester::new();
|
||||||
let response = requester
|
let response = requester
|
||||||
.get_raw_with_headers_timeout(url, headers, Some(VALIDATION_MEDIA_TIMEOUT))
|
.get_raw_with_headers_timeout(url, headers, Some(VALIDATION_MEDIA_TIMEOUT), None)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
@@ -1928,6 +1928,7 @@ mod tests {
|
|||||||
&first.thumb,
|
&first.thumb,
|
||||||
vec![("Range".to_string(), "bytes=0-1023".to_string())],
|
vec![("Range".to_string(), "bytes=0-1023".to_string())],
|
||||||
Some(Duration::from_secs(30)),
|
Some(Duration::from_secs(30)),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("thumbnail request should succeed");
|
.expect("thumbnail request should succeed");
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ impl ArchivebateProxy {
|
|||||||
embed_url,
|
embed_url,
|
||||||
Self::html_headers(detail_url),
|
Self::html_headers(detail_url),
|
||||||
Some(StdDuration::from_secs(8)),
|
Some(StdDuration::from_secs(8)),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|||||||
@@ -16,21 +16,24 @@ impl LulustreamProxy {
|
|||||||
LulustreamProxy {}
|
LulustreamProxy {}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_detail_request(endpoint: &str) -> Option<(String, String)> {
|
fn normalize_detail_request(endpoint: &str) -> Option<(String, String, String)> {
|
||||||
let endpoint = endpoint.trim().trim_start_matches('/');
|
let endpoint = endpoint.trim().trim_start_matches('/');
|
||||||
if endpoint.is_empty() {
|
if endpoint.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let detail_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
let (detail_url, host) = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||||
endpoint.to_string()
|
(endpoint.to_string(), None)
|
||||||
} else if endpoint.starts_with("lulustream.com/")
|
} else if endpoint.starts_with("lulustream.com/")
|
||||||
|| endpoint.starts_with("www.lulustream.com/")
|
|| endpoint.starts_with("www.lulustream.com/")
|
||||||
|| endpoint.starts_with("luluvid.com/")
|
|
||||||
{
|
{
|
||||||
format!("https://{endpoint}")
|
(format!("https://{endpoint}"), Some("lulustream.com"))
|
||||||
|
} else if endpoint.starts_with("luluvid.com/") {
|
||||||
|
(format!("https://{endpoint}"), Some("luluvid.com"))
|
||||||
|
} else if endpoint.starts_with("luluvdo.com/") {
|
||||||
|
(format!("https://{endpoint}"), Some("luluvdo.com"))
|
||||||
} else {
|
} else {
|
||||||
format!("https://lulustream.com/{endpoint}")
|
(format!("https://lulustream.com/{endpoint}"), Some("lulustream.com"))
|
||||||
};
|
};
|
||||||
|
|
||||||
if !Self::is_allowed_detail_url(&detail_url) {
|
if !Self::is_allowed_detail_url(&detail_url) {
|
||||||
@@ -40,7 +43,13 @@ impl LulustreamProxy {
|
|||||||
let parsed = Url::parse(&detail_url).ok()?;
|
let parsed = Url::parse(&detail_url).ok()?;
|
||||||
let video_id = parsed.path_segments()?.last().map(ToOwned::to_owned)?;
|
let video_id = parsed.path_segments()?.last().map(ToOwned::to_owned)?;
|
||||||
|
|
||||||
Some((detail_url, video_id))
|
// Determine embed host from URL or default to luluvid.com
|
||||||
|
let embed_host = host
|
||||||
|
.map(String::from)
|
||||||
|
.or_else(|| parsed.host_str().map(String::from))
|
||||||
|
.unwrap_or_else(|| "luluvid.com".to_string());
|
||||||
|
|
||||||
|
Some((detail_url, video_id, embed_host))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_allowed_detail_url(url: &str) -> bool {
|
fn is_allowed_detail_url(url: &str) -> bool {
|
||||||
@@ -53,7 +62,7 @@ impl LulustreamProxy {
|
|||||||
let Some(host) = parsed.host_str() else {
|
let Some(host) = parsed.host_str() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
(host == "lulustream.com" || host == "www.lulustream.com" || host == "luluvid.com")
|
(host == "lulustream.com" || host == "www.lulustream.com" || host == "luluvid.com" || host == "luluvdo.com")
|
||||||
&& !parsed.path().is_empty()
|
&& !parsed.path().is_empty()
|
||||||
&& parsed.path() != "/"
|
&& parsed.path() != "/"
|
||||||
}
|
}
|
||||||
@@ -114,11 +123,11 @@ impl LulustreamProxy {
|
|||||||
|
|
||||||
impl crate::proxies::Proxy for LulustreamProxy {
|
impl crate::proxies::Proxy for LulustreamProxy {
|
||||||
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
|
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
|
||||||
let Some((_detail_url, video_id)) = Self::normalize_detail_request(&url) else {
|
let Some((_detail_url, video_id, embed_host)) = Self::normalize_detail_request(&url) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
|
|
||||||
let embed_url = format!("https://luluvid.com/e/{video_id}");
|
let embed_url = format!("https://{embed_host}/e/{video_id}");
|
||||||
|
|
||||||
// Chrome120 emulation bypasses Cloudflare; try it first.
|
// Chrome120 emulation bypasses Cloudflare; try it first.
|
||||||
if let Some(media_url) = Self::try_chrome_extraction(&embed_url).await {
|
if let Some(media_url) = Self::try_chrome_extraction(&embed_url).await {
|
||||||
@@ -138,28 +147,41 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalizes_detail_request_with_full_url() {
|
fn normalizes_detail_request_with_full_url() {
|
||||||
let (url, video_id) =
|
let (url, video_id, embed_host) =
|
||||||
LulustreamProxy::normalize_detail_request("https://lulustream.com/d/s484n23k8opy")
|
LulustreamProxy::normalize_detail_request("https://lulustream.com/d/s484n23k8opy")
|
||||||
.expect("detail request should parse");
|
.expect("detail request should parse");
|
||||||
assert_eq!(url, "https://lulustream.com/d/s484n23k8opy");
|
assert_eq!(url, "https://lulustream.com/d/s484n23k8opy");
|
||||||
assert_eq!(video_id, "s484n23k8opy");
|
assert_eq!(video_id, "s484n23k8opy");
|
||||||
|
assert_eq!(embed_host, "lulustream.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalizes_detail_request_with_path_only() {
|
fn normalizes_detail_request_with_path_only() {
|
||||||
let (url, video_id) = LulustreamProxy::normalize_detail_request("d/s484n23k8opy")
|
let (url, video_id, embed_host) = LulustreamProxy::normalize_detail_request("d/s484n23k8opy")
|
||||||
.expect("detail request should parse");
|
.expect("detail request should parse");
|
||||||
assert_eq!(url, "https://lulustream.com/d/s484n23k8opy");
|
assert_eq!(url, "https://lulustream.com/d/s484n23k8opy");
|
||||||
assert_eq!(video_id, "s484n23k8opy");
|
assert_eq!(video_id, "s484n23k8opy");
|
||||||
|
assert_eq!(embed_host, "lulustream.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalizes_luluvid_url() {
|
fn normalizes_luluvid_url() {
|
||||||
let (url, video_id) =
|
let (url, video_id, embed_host) =
|
||||||
LulustreamProxy::normalize_detail_request("https://luluvid.com/e/s484n23k8opy")
|
LulustreamProxy::normalize_detail_request("https://luluvid.com/e/s484n23k8opy")
|
||||||
.expect("detail request should parse");
|
.expect("detail request should parse");
|
||||||
assert_eq!(url, "https://luluvid.com/e/s484n23k8opy");
|
assert_eq!(url, "https://luluvid.com/e/s484n23k8opy");
|
||||||
assert_eq!(video_id, "s484n23k8opy");
|
assert_eq!(video_id, "s484n23k8opy");
|
||||||
|
assert_eq!(embed_host, "luluvid.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_luluvdo_url() {
|
||||||
|
let (url, video_id, embed_host) =
|
||||||
|
LulustreamProxy::normalize_detail_request("https://luluvdo.com/e/s484n23k8opy")
|
||||||
|
.expect("detail request should parse");
|
||||||
|
assert_eq!(url, "https://luluvdo.com/e/s484n23k8opy");
|
||||||
|
assert_eq!(video_id, "s484n23k8opy");
|
||||||
|
assert_eq!(embed_host, "luluvdo.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub mod tube8;
|
|||||||
pub mod thepornbunny;
|
pub mod thepornbunny;
|
||||||
pub mod vidara;
|
pub mod vidara;
|
||||||
pub mod vjav;
|
pub mod vjav;
|
||||||
|
pub mod xiaoshenke;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AnyProxy {
|
pub enum AnyProxy {
|
||||||
|
|||||||
296
src/proxies/xiaoshenke.rs
Normal file
296
src/proxies/xiaoshenke.rs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use ntex::http::Method;
|
||||||
|
use ntex::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE};
|
||||||
|
use ntex::util::Bytes;
|
||||||
|
use ntex::web::{self, HttpRequest};
|
||||||
|
|
||||||
|
use crate::util::flaresolverr::{FlareSolverrRequest, Flaresolverr};
|
||||||
|
use crate::util::requester::Requester;
|
||||||
|
|
||||||
|
const FIREFOX_USER_AGENT: &str =
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0";
|
||||||
|
const HTML_ACCEPT: &str =
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct XiaoshenkeProxy {}
|
||||||
|
|
||||||
|
impl XiaoshenkeProxy {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
XiaoshenkeProxy {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the video ID and quality bitmask from the embed page URL.
|
||||||
|
/// Embed URL format: //xiaoshenke.net/video/{id}/{quality_bitmask}
|
||||||
|
/// Quality bitmask: 1=360p, 2=480p, 4=720p, 8=1080p (bitwise flags)
|
||||||
|
fn parse_embed_url(endpoint: &str) -> Option<(String, u8)> {
|
||||||
|
let endpoint = endpoint.trim().trim_start_matches('/');
|
||||||
|
if !endpoint.starts_with("video/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = endpoint.split('/').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let id = parts[1].to_string();
|
||||||
|
let quality_bitmask = parts[2].parse::<u8>().ok()?;
|
||||||
|
Some((id, quality_bitmask))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quality bitmask to height mapping (picks highest available quality)
|
||||||
|
/// Bitmask: 1=360p, 2=480p, 4=720p, 8=1080p
|
||||||
|
fn quality_bitmask_to_height(bitmask: u8) -> u32 {
|
||||||
|
if bitmask & 8 != 0 {
|
||||||
|
1080
|
||||||
|
} else if bitmask & 4 != 0 {
|
||||||
|
720
|
||||||
|
} else if bitmask & 2 != 0 {
|
||||||
|
480
|
||||||
|
} else if bitmask & 1 != 0 {
|
||||||
|
360
|
||||||
|
} else {
|
||||||
|
720 // default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse the video ID (as done in the embed page JavaScript)
|
||||||
|
fn reverse_id(id: &str) -> String {
|
||||||
|
id.chars().rev().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the video CDN URL from reversed ID and quality
|
||||||
|
fn build_cdn_url(reversed_id: &str, quality: u32) -> String {
|
||||||
|
format!("https://xiaoshenke.net/vid/{}/{}", reversed_id, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the embed page using FlareSolverr to solve Cloudflare challenge,
|
||||||
|
/// then extract the signed CDN URL.
|
||||||
|
async fn get_signed_url(
|
||||||
|
&self,
|
||||||
|
video_id: &str,
|
||||||
|
quality_bitmask: u8,
|
||||||
|
requester: &mut Requester,
|
||||||
|
) -> Option<String> {
|
||||||
|
let embed_url = format!("https://xiaoshenke.net/video/{}/{}", video_id, quality_bitmask);
|
||||||
|
|
||||||
|
// Use FlareSolverr to solve the Cloudflare challenge on the embed page if FLARE_URL is set.
|
||||||
|
// This gives us the clearance cookies and user agent needed for subsequent requests.
|
||||||
|
if let Ok(flare_url) = std::env::var("FLARE_URL") {
|
||||||
|
let mut flare = Flaresolverr::new(flare_url);
|
||||||
|
if requester.proxy {
|
||||||
|
if std::env::var("BURP_URL").is_ok() {
|
||||||
|
flare.set_proxy(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let flare_res = flare
|
||||||
|
.solve(FlareSolverrRequest {
|
||||||
|
cmd: "request.get".to_string(),
|
||||||
|
url: embed_url.clone(),
|
||||||
|
maxTimeout: 60000,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
// Store FlareSolverr cookies and user agent in the requester for subsequent requests
|
||||||
|
let useragent = flare_res.solution.userAgent;
|
||||||
|
crate::util::requester::Requester::remember_flaresolverr_user_agent(&embed_url, &useragent);
|
||||||
|
requester.user_agent = Some(useragent);
|
||||||
|
requester.store_flaresolverr_cookies(&embed_url, &flare_res.solution.cookies);
|
||||||
|
requester.client = crate::util::requester::Requester::build_client(
|
||||||
|
requester.cookie_jar.clone(),
|
||||||
|
requester.user_agent.as_deref(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback: try direct request (might work if no challenge)
|
||||||
|
let _ = requester
|
||||||
|
.get_with_headers(
|
||||||
|
&embed_url,
|
||||||
|
vec![
|
||||||
|
("Referer".to_string(), "https://fullporner.com/".to_string()),
|
||||||
|
("User-Agent".to_string(), FIREFOX_USER_AGENT.to_string()),
|
||||||
|
("Accept".to_string(), HTML_ACCEPT.to_string()),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The embed page has JavaScript that builds the video URL.
|
||||||
|
// Pattern: var id = "xxx".split('').reverse().join('');
|
||||||
|
// Then: `${prefix}/${id}/${quality}`
|
||||||
|
// We can extract the ID from the page and reverse it ourselves.
|
||||||
|
let reversed_id = Self::reverse_id(video_id);
|
||||||
|
let quality = Self::quality_bitmask_to_height(quality_bitmask);
|
||||||
|
let cdn_url = Self::build_cdn_url(&reversed_id, quality);
|
||||||
|
|
||||||
|
// Get the signed URL by requesting CDN URL WITHOUT following redirect
|
||||||
|
let upstream = requester
|
||||||
|
.get_no_redirect_raw_with_headers(
|
||||||
|
&cdn_url,
|
||||||
|
vec![("Referer".to_string(), embed_url.clone())],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if upstream.status().is_redirection() {
|
||||||
|
let location = upstream.headers().get("Location")?.to_str().ok()?;
|
||||||
|
let signed_url = if location.starts_with("//") {
|
||||||
|
format!("https:{}", location)
|
||||||
|
} else {
|
||||||
|
location.to_string()
|
||||||
|
};
|
||||||
|
// Return signed URL - serve_media will fetch it WITHOUT cookie jar (no cookies to v5.xiaoshenke.net)
|
||||||
|
Some(signed_url)
|
||||||
|
} else {
|
||||||
|
Some(cdn_url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve_media(
|
||||||
|
req: HttpRequest,
|
||||||
|
requester: web::types::State<Requester>,
|
||||||
|
) -> Result<impl web::Responder, web::Error> {
|
||||||
|
let endpoint = req.match_info().query("endpoint").to_string();
|
||||||
|
let proxy = XiaoshenkeProxy::new();
|
||||||
|
|
||||||
|
let Some((video_id, quality_bitmask)) = XiaoshenkeProxy::parse_embed_url(&endpoint) else {
|
||||||
|
return Ok(web::HttpResponse::BadRequest().finish());
|
||||||
|
};
|
||||||
|
|
||||||
|
// HEAD: answer probes/health-checks directly without resolving the signed URL
|
||||||
|
if req.method() == Method::HEAD {
|
||||||
|
return Ok(web::HttpResponse::Ok()
|
||||||
|
.header(CONTENT_TYPE, "video/mp4")
|
||||||
|
.header(ACCEPT_RANGES, "bytes")
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET/POST: get signed URL via FlareSolverr + CDN redirect, then fetch signed URL with fresh client (no cookie jar)
|
||||||
|
let mut requester = requester.get_ref().clone();
|
||||||
|
let Some(signed_url) = proxy.get_signed_url(&video_id, quality_bitmask, &mut requester).await else {
|
||||||
|
return Ok(web::HttpResponse::BadGateway().finish());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract Range header for seeking support
|
||||||
|
let range_header = req
|
||||||
|
.headers()
|
||||||
|
.get(RANGE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
// Fetch the signed URL with a fresh client (no cookie jar) since signed URL doesn't need cookies
|
||||||
|
let mut headers = vec![
|
||||||
|
("Referer".to_string(), format!("https://xiaoshenke.net/video/{}/{}", video_id, quality_bitmask)),
|
||||||
|
("User-Agent".to_string(), FIREFOX_USER_AGENT.to_string()),
|
||||||
|
];
|
||||||
|
if let Some(range) = &range_header {
|
||||||
|
headers.push(("Range".to_string(), range.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a fresh client without cookie jar for the signed URL request
|
||||||
|
let client = crate::util::requester::Requester::build_client(
|
||||||
|
Arc::new(wreq::cookie::Jar::default()),
|
||||||
|
Some(FIREFOX_USER_AGENT),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut request = client.get(&signed_url);
|
||||||
|
for (key, value) in &headers {
|
||||||
|
request = request.header(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstream = match request.send().await {
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
crate::flow_debug!("xiaoshenke: signed URL request error: {}", e);
|
||||||
|
return Ok(web::HttpResponse::BadGateway().finish());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = upstream.status();
|
||||||
|
let upstream_headers = upstream.headers().clone();
|
||||||
|
let content_type = upstream_headers
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("video/mp4")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut builder = web::HttpResponse::build(status);
|
||||||
|
builder.header(CONTENT_TYPE, &content_type);
|
||||||
|
builder.header(ACCEPT_RANGES, "bytes");
|
||||||
|
|
||||||
|
if let Some(cl) = upstream_headers.get(CONTENT_LENGTH) {
|
||||||
|
if let Ok(cl_str) = cl.to_str() {
|
||||||
|
builder.header(CONTENT_LENGTH, cl_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(cr) = upstream_headers.get(CONTENT_RANGE) {
|
||||||
|
if let Ok(cr_str) = cr.to_str() {
|
||||||
|
builder.header(CONTENT_RANGE, cr_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the body using wreq's into_data_stream (yields Result<bytes::Bytes, wreq::Error>)
|
||||||
|
// Convert to ntex::util::Bytes for the response
|
||||||
|
let byte_stream = upstream.into_data_stream();
|
||||||
|
let stream = Box::pin(byte_stream.map(|chunk| {
|
||||||
|
chunk
|
||||||
|
.map(|b| Bytes::copy_from_slice(b.as_ref()))
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
|
||||||
|
}));
|
||||||
|
|
||||||
|
Ok(builder.streaming(stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::XiaoshenkeProxy;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_embed_url() {
|
||||||
|
assert_eq!(
|
||||||
|
XiaoshenkeProxy::parse_embed_url("video/c9266c6648b96/4"),
|
||||||
|
Some(("c9266c6648b96".to_string(), 4))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
XiaoshenkeProxy::parse_embed_url("/video/abc123/3"),
|
||||||
|
Some(("abc123".to_string(), 3))
|
||||||
|
);
|
||||||
|
assert_eq!(XiaoshenkeProxy::parse_embed_url("other/abc/1"), None);
|
||||||
|
assert_eq!(XiaoshenkeProxy::parse_embed_url("video/onlytwo"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reverses_id() {
|
||||||
|
assert_eq!(XiaoshenkeProxy::reverse_id("c9266c6648b96"), "69b8466c6629c");
|
||||||
|
assert_eq!(XiaoshenkeProxy::reverse_id("abc123"), "321cba");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quality_mapping() {
|
||||||
|
// Test bitmask values
|
||||||
|
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(1), 360); // 360p
|
||||||
|
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(2), 480); // 480p
|
||||||
|
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(3), 480); // 360+480 -> highest 480
|
||||||
|
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(4), 720); // 720p
|
||||||
|
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(6), 720); // 480+720 -> highest 720
|
||||||
|
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(7), 720); // 360+480+720 -> highest 720
|
||||||
|
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(8), 1080); // 1080p
|
||||||
|
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(14), 1080); // 480+720+1080 -> highest 1080
|
||||||
|
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(15), 1080); // all -> highest 1080
|
||||||
|
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(0), 720); // default
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_cdn_url() {
|
||||||
|
assert_eq!(
|
||||||
|
XiaoshenkeProxy::build_cdn_url("69b8466c6629c", 720),
|
||||||
|
"https://xiaoshenke.net/vid/69b8466c6629c/720"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,6 +189,12 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(web::post().to(crate::proxies::melonstube::serve))
|
.route(web::post().to(crate::proxies::melonstube::serve))
|
||||||
.route(web::get().to(crate::proxies::melonstube::serve)),
|
.route(web::get().to(crate::proxies::melonstube::serve)),
|
||||||
);
|
);
|
||||||
|
cfg.service(
|
||||||
|
web::resource("/xiaoshenke/{endpoint}*")
|
||||||
|
.route(web::post().to(crate::proxies::xiaoshenke::serve_media))
|
||||||
|
.route(web::get().to(crate::proxies::xiaoshenke::serve_media))
|
||||||
|
.route(web::head().to(crate::proxies::xiaoshenke::serve_media)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn proxy2redirect(
|
async fn proxy2redirect(
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ type AnyErr = Box<dyn std::error::Error + Send + Sync + 'static>;
|
|||||||
#[derive(serde::Serialize, serde::Deserialize, Clone)]
|
#[derive(serde::Serialize, serde::Deserialize, Clone)]
|
||||||
pub struct Requester {
|
pub struct Requester {
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
client: Client,
|
pub(crate) client: Client,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
cookie_jar: Arc<Jar>,
|
pub(crate) cookie_jar: Arc<Jar>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
debug_trace_id: Option<String>,
|
pub(crate) debug_trace_id: Option<String>,
|
||||||
proxy: bool,
|
pub(crate) proxy: bool,
|
||||||
flaresolverr_session: Option<String>,
|
flaresolverr_session: Option<String>,
|
||||||
user_agent: Option<String>,
|
pub(crate) user_agent: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for Requester {
|
impl fmt::Debug for Requester {
|
||||||
@@ -75,7 +75,7 @@ impl Requester {
|
|||||||
.map(|host| host.trim_start_matches("www.").to_ascii_lowercase())
|
.map(|host| host.trim_start_matches("www.").to_ascii_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remember_flaresolverr_user_agent(url: &str, user_agent: &str) {
|
pub(crate) fn remember_flaresolverr_user_agent(url: &str, user_agent: &str) {
|
||||||
if user_agent.is_empty() {
|
if user_agent.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ impl Requester {
|
|||||||
default.clone()
|
default.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn store_flaresolverr_cookies(
|
pub(crate) fn store_flaresolverr_cookies(
|
||||||
&mut self,
|
&mut self,
|
||||||
request_url: &str,
|
request_url: &str,
|
||||||
cookies: &[crate::util::flaresolverr::FlaresolverrCookie],
|
cookies: &[crate::util::flaresolverr::FlaresolverrCookie],
|
||||||
@@ -280,7 +280,7 @@ impl Requester {
|
|||||||
.unwrap_or_else(|| "none".to_string())
|
.unwrap_or_else(|| "none".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_client(cookie_jar: Arc<Jar>, user_agent: Option<&str>) -> Client {
|
pub(crate) fn build_client(cookie_jar: Arc<Jar>, user_agent: Option<&str>) -> Client {
|
||||||
let mut builder = Client::builder()
|
let mut builder = Client::builder()
|
||||||
.tls_cert_verification(false)
|
.tls_cert_verification(false)
|
||||||
.emulation(Emulation::Firefox151)
|
.emulation(Emulation::Firefox151)
|
||||||
@@ -427,12 +427,66 @@ impl Requester {
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`get_raw_with_headers`], but disables redirect-following so a 3xx response's
|
||||||
|
/// `Location` header can be inspected directly.
|
||||||
|
pub async fn get_no_redirect_raw_with_headers(
|
||||||
|
&mut self,
|
||||||
|
url: &str,
|
||||||
|
headers: Vec<(String, String)>,
|
||||||
|
) -> Result<Response, wreq::Error> {
|
||||||
|
self.adopt_remembered_identity(url);
|
||||||
|
let cookie_preview = self
|
||||||
|
.cookie_header_for_url(url)
|
||||||
|
.map(|cookie| crate::util::flow_debug::preview(&cookie, 160))
|
||||||
|
.unwrap_or_else(|| "none".to_string());
|
||||||
|
#[cfg(not(feature = "debug"))]
|
||||||
|
let _ = &cookie_preview;
|
||||||
|
crate::flow_debug!(
|
||||||
|
"trace={} requester get_no_redirect_raw_with_headers url={} headers={} cookies={} proxy={}",
|
||||||
|
self.debug_trace_id().unwrap_or("none"),
|
||||||
|
crate::util::flow_debug::preview(url, 120),
|
||||||
|
headers.len(),
|
||||||
|
cookie_preview,
|
||||||
|
self.proxy
|
||||||
|
);
|
||||||
|
let mut builder = Client::builder()
|
||||||
|
.tls_cert_verification(false)
|
||||||
|
.emulation(Emulation::Firefox151)
|
||||||
|
.cookie_provider(self.cookie_jar.clone())
|
||||||
|
.redirect(Policy::none());
|
||||||
|
if let Some(user_agent) = self.user_agent.as_deref() {
|
||||||
|
let mut hdrs = HeaderMap::new();
|
||||||
|
if let Ok(value) = HeaderValue::from_str(user_agent) {
|
||||||
|
hdrs.insert(USER_AGENT, value);
|
||||||
|
builder = builder.default_headers(hdrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let client = builder.build().expect("Failed to create HTTP client");
|
||||||
|
|
||||||
|
let mut request = client.get(url).version(Version::HTTP_11);
|
||||||
|
|
||||||
|
if self.proxy {
|
||||||
|
if let Ok(proxy_url) = env::var("BURP_URL") {
|
||||||
|
let proxy = Proxy::all(&proxy_url).unwrap();
|
||||||
|
request = request.proxy(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Set custom headers
|
||||||
|
for (key, value) in headers.iter() {
|
||||||
|
request = request.header(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await?;
|
||||||
|
self.store_response_cookies(url, &response);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_raw_with_headers(
|
pub async fn get_raw_with_headers(
|
||||||
&mut self,
|
&mut self,
|
||||||
url: &str,
|
url: &str,
|
||||||
headers: Vec<(String, String)>,
|
headers: Vec<(String, String)>,
|
||||||
) -> Result<Response, wreq::Error> {
|
) -> Result<Response, wreq::Error> {
|
||||||
self.get_raw_with_headers_timeout(url, headers, None).await
|
self.get_raw_with_headers_timeout(url, headers, None, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_raw_with_headers_timeout(
|
pub async fn get_raw_with_headers_timeout(
|
||||||
@@ -440,21 +494,28 @@ impl Requester {
|
|||||||
url: &str,
|
url: &str,
|
||||||
headers: Vec<(String, String)>,
|
headers: Vec<(String, String)>,
|
||||||
timeout: Option<Duration>,
|
timeout: Option<Duration>,
|
||||||
|
http_version: Option<Version>,
|
||||||
) -> Result<Response, wreq::Error> {
|
) -> Result<Response, wreq::Error> {
|
||||||
let cookie_preview = self.debug_cookie_preview_from_owned_headers(url, &headers);
|
let cookie_preview = self.debug_cookie_preview_from_owned_headers(url, &headers);
|
||||||
#[cfg(not(feature = "debug"))]
|
#[cfg(not(feature = "debug"))]
|
||||||
let _ = &cookie_preview;
|
let _ = &cookie_preview;
|
||||||
crate::flow_debug!(
|
crate::flow_debug!(
|
||||||
"trace={} requester get_raw_with_headers url={} headers={} cookies={} proxy={}",
|
"trace={} requester get_raw_with_headers url={} headers={} cookies={} http_version={:?} proxy={}",
|
||||||
self.debug_trace_id().unwrap_or("none"),
|
self.debug_trace_id().unwrap_or("none"),
|
||||||
crate::util::flow_debug::preview(url, 120),
|
crate::util::flow_debug::preview(url, 120),
|
||||||
headers.len(),
|
headers.len(),
|
||||||
cookie_preview,
|
cookie_preview,
|
||||||
|
http_version,
|
||||||
self.proxy
|
self.proxy
|
||||||
);
|
);
|
||||||
let client = Self::build_client(self.cookie_jar.clone(), self.user_agent.as_deref());
|
let client = Self::build_client(self.cookie_jar.clone(), self.user_agent.as_deref());
|
||||||
|
|
||||||
let mut request = client.get(url).version(Version::HTTP_11);
|
let mut request = client.get(url);
|
||||||
|
if let Some(version) = http_version {
|
||||||
|
request = request.version(version);
|
||||||
|
} else {
|
||||||
|
request = request.version(Version::HTTP_11);
|
||||||
|
}
|
||||||
if let Some(timeout) = timeout {
|
if let Some(timeout) = timeout {
|
||||||
request = request.timeout(timeout);
|
request = request.timeout(timeout);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user