video from url

This commit is contained in:
Simon
2026-04-01 12:21:14 +00:00
parent e2796bfd71
commit b07d269154
2 changed files with 269 additions and 2 deletions

View File

@@ -12,9 +12,12 @@ use crate::{DbPool, db, status::*, videos::*};
use ntex::http::header;
use ntex::web;
use ntex::web::HttpRequest;
use serde_json::Value;
use std::cmp::Ordering;
use std::io;
use std::process::Command;
use tokio::task;
use url::Url;
#[derive(Debug, Clone)]
pub struct ClientVersion {
@@ -132,6 +135,154 @@ fn video_matches_literal_query(video: &VideoItem, literal_query: &str) -> bool {
.is_some_and(|tags| tags.iter().any(|tag| contains_literal(tag)))
}
fn normalize_query_url(query: &str) -> Option<String> {
let trimmed = query.trim();
if trimmed.is_empty() {
return None;
}
let parsed = Url::parse(trimmed).ok()?;
match parsed.scheme() {
"http" | "https" => Some(parsed.to_string()),
_ => None,
}
}
fn video_item_from_ytdlp_payload(
channel: &str,
fallback_url: &str,
payload: &Value,
) -> Option<VideoItem> {
let title = payload
.get("title")
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())?
.to_string();
let page_url = payload
.get("webpage_url")
.and_then(|value| value.as_str())
.filter(|value| value.starts_with("http://") || value.starts_with("https://"))
.unwrap_or(fallback_url)
.to_string();
let id = payload
.get("id")
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
Url::parse(&page_url)
.ok()
.and_then(|parsed| parsed.path_segments()?.next_back().map(ToOwned::to_owned))
})?;
let thumb = payload
.get("thumbnail")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
let duration = payload
.get("duration")
.and_then(|value| value.as_u64())
.and_then(|value| u32::try_from(value).ok())
.unwrap_or(0);
let mut item = VideoItem::new(id, title, page_url, channel.to_string(), thumb, duration);
item.views = payload
.get("view_count")
.and_then(|value| value.as_u64())
.and_then(|value| u32::try_from(value).ok());
item.uploader = payload
.get("uploader")
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned);
item.uploaderUrl = payload
.get("uploader_url")
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned);
item.preview = payload
.get("thumbnail")
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned);
let formats = payload
.get("formats")
.and_then(|value| value.as_array())
.map(|entries| {
entries
.iter()
.filter_map(|format| {
let format_url =
format
.get("url")
.and_then(|value| value.as_str())
.filter(|value| {
value.starts_with("http://") || value.starts_with("https://")
})?;
let quality = format
.get("format_id")
.and_then(|value| value.as_str())
.or_else(|| format.get("format").and_then(|value| value.as_str()))
.or_else(|| format.get("resolution").and_then(|value| value.as_str()))
.unwrap_or("auto")
.to_string();
let ext = format
.get("ext")
.and_then(|value| value.as_str())
.unwrap_or("mp4")
.to_string();
let mut video_format =
VideoFormat::new(format_url.to_string(), quality.clone(), ext)
.format_id(quality.clone());
if let Some(note) = format.get("format_note").and_then(|value| value.as_str()) {
if !note.trim().is_empty() {
video_format = video_format.format_note(note.to_string());
}
}
Some(video_format)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !formats.is_empty() {
item.formats = Some(formats);
}
Some(item)
}
fn videos_from_ytdlp_query_url(
channel: &str,
query_url: &str,
limit: usize,
) -> Option<Vec<VideoItem>> {
let output = Command::new("yt-dlp")
.arg("-J")
.arg("--no-warnings")
.arg("--extractor-args")
.arg("generic:impersonate=chrome")
.arg(query_url)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let payload: Value = serde_json::from_slice(&output.stdout).ok()?;
if let Some(entries) = payload.get("entries").and_then(|value| value.as_array()) {
let items = entries
.iter()
.filter_map(|entry| video_item_from_ytdlp_payload(channel, query_url, entry))
.take(limit)
.collect::<Vec<_>>();
return (!items.is_empty()).then_some(items);
}
video_item_from_ytdlp_payload(channel, query_url, &payload).map(|item| vec![item])
}
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::resource("/status")
@@ -418,6 +569,65 @@ async fn videos_post(
sort: Some(sort.clone()),
sexuality: Some(sexuality),
};
if let Some(query_url) = query.as_deref().and_then(normalize_query_url) {
crate::flow_debug!(
"trace={} videos attempting ytdlp url fast path provider={} url={}",
trace_id,
&channel,
crate::util::flow_debug::preview(&query_url, 160)
);
if let Some(mut video_items) =
videos_from_ytdlp_query_url(&channel, &query_url, perPage as usize)
{
if clientversion == ClientVersion::new(38, 0, "Hot%20Tub".to_string()) {
video_items = video_items
.into_iter()
.filter_map(|video| {
let last_url = video
.formats
.as_ref()
.and_then(|formats| formats.last().map(|f| f.url.clone()));
if let Some(url) = last_url {
let mut v = video;
v.url = url;
return Some(v);
}
Some(video)
})
.collect();
}
for video in video_items.iter_mut() {
if video.duration <= 120 {
let mut preview_url = video.url.clone();
if let Some(formats) = &video.formats {
if let Some(first) = formats.first() {
preview_url = first.url.clone();
}
}
video.preview = Some(preview_url);
}
}
videos.pageInfo = PageInfo {
hasNextPage: false,
resultsPerPage: perPage as u32,
};
videos.items = video_items;
crate::flow_debug!(
"trace={} videos ytdlp url fast path returned count={}",
trace_id,
videos.items.len()
);
return Ok(web::HttpResponse::Ok().json(&videos));
}
crate::flow_debug!(
"trace={} videos ytdlp url fast path fell back to provider",
trace_id
);
}
crate::flow_debug!(
"trace={} videos provider dispatch provider={} literal_query={:?}",
trace_id,
@@ -774,4 +984,61 @@ mod tests {
assert!(uploader_match_sort_key(&b) > uploader_match_sort_key(&a));
}
#[test]
fn detects_http_and_https_query_urls() {
assert_eq!(
normalize_query_url(" https://www.freeuseporn.com/video/9579/example "),
Some("https://www.freeuseporn.com/video/9579/example".to_string())
);
assert_eq!(
normalize_query_url("http://example.com/video"),
Some("http://example.com/video".to_string())
);
assert_eq!(normalize_query_url("Nicole Kitt"), None);
assert_eq!(normalize_query_url("ftp://example.com/video"), None);
}
#[test]
fn builds_video_item_from_ytdlp_payload() {
let payload = serde_json::json!({
"id": "9579",
"title": "Nicole Kitt - Example",
"webpage_url": "https://www.freeuseporn.com/video/9579/nicole-kitt-example",
"thumbnail": "https://www.freeuseporn.com/media/videos/tmb/9579/1.jpg",
"duration": 3549,
"view_count": 52180,
"uploader": "FreeusePorn",
"formats": [
{
"url": "https://www.freeuseporn.com/media/videos/h264/9579_720p.mp4",
"format_id": "720p",
"format_note": "720p",
"ext": "mp4"
},
{
"url": "https://www.freeuseporn.com/media/videos/h264/9579_480p.mp4",
"format_id": "480p",
"ext": "mp4"
}
]
});
let item = video_item_from_ytdlp_payload(
"freeuseporn",
"https://www.freeuseporn.com/video/9579/nicole-kitt-example",
&payload,
)
.expect("item should parse");
assert_eq!(item.id, "9579");
assert_eq!(item.title, "Nicole Kitt - Example");
assert_eq!(
item.url,
"https://www.freeuseporn.com/video/9579/nicole-kitt-example"
);
assert_eq!(item.views, Some(52180));
assert_eq!(item.uploader.as_deref(), Some("FreeusePorn"));
assert_eq!(item.formats.as_ref().map(|formats| formats.len()), Some(2));
}
}

View File

@@ -313,13 +313,13 @@ impl FreeusepornProvider {
thumb,
duration,
)
.views(views.unwrap_or(0))
.formats(self.build_formats(&id));
.views(views.unwrap_or(0));
if views.is_none() {
item.views = None;
}
item.rating = rating;
item.formats = Some(self.build_formats(&id));
Some(item)
}