From b07d26915433484342b96694bd8fafca387d295d Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 1 Apr 2026 12:21:14 +0000 Subject: [PATCH] video from url --- src/api.rs | 267 +++++++++++++++++++++++++++++++++++ src/providers/freeuseporn.rs | 4 +- 2 files changed, 269 insertions(+), 2 deletions(-) diff --git a/src/api.rs b/src/api.rs index 27565b1..18f68f0 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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 { + 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 { + 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::>() + }) + .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> { + 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::>(); + 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)); + } } diff --git a/src/providers/freeuseporn.rs b/src/providers/freeuseporn.rs index e77b14f..114ce4c 100644 --- a/src/providers/freeuseporn.rs +++ b/src/providers/freeuseporn.rs @@ -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) }