upgrades
This commit is contained in:
345
src/api.rs
345
src/api.rs
@@ -83,6 +83,40 @@ impl Ord for ClientVersion {
|
||||
}
|
||||
}
|
||||
|
||||
fn client_version_from_request(req: &HttpRequest) -> ClientVersion {
|
||||
match req.headers().get("User-Agent") {
|
||||
Some(v) => match v.to_str() {
|
||||
Ok(useragent) => ClientVersion::parse(useragent)
|
||||
.unwrap_or_else(|| ClientVersion::new(999, 0, "Hot%20Tub".to_string())),
|
||||
Err(_) => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
|
||||
},
|
||||
_ => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_videos_table(pool: &DbPool) {
|
||||
match pool.get() {
|
||||
Ok(mut conn) => match db::has_table(&mut conn, "videos") {
|
||||
Ok(false) => {
|
||||
if let Err(e) = db::create_table(
|
||||
&mut conn,
|
||||
"CREATE TABLE videos (id TEXT NOT NULL, url TEXT NOT NULL);",
|
||||
) {
|
||||
report_provider_error("db", "ensure_videos_table.create_table", &e.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(true) => {}
|
||||
Err(e) => {
|
||||
report_provider_error("db", "ensure_videos_table.has_table", &e.to_string()).await;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
report_provider_error("db", "ensure_videos_table.pool_get", &e.to_string()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_query(raw_query: Option<&str>) -> (Option<String>, Option<String>) {
|
||||
let Some(raw_query) = raw_query else {
|
||||
return (None, None);
|
||||
@@ -130,6 +164,55 @@ fn video_matches_literal_query(video: &VideoItem, literal_query: &str) -> bool {
|
||||
.is_some_and(|tags| tags.iter().any(|tag| contains_literal(tag)))
|
||||
}
|
||||
|
||||
fn normalize_uploader_name(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.trim_start_matches('#')
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn video_matches_normalized_uploader(video: &VideoItem, normalized_uploader: &str) -> bool {
|
||||
video
|
||||
.uploader
|
||||
.as_deref()
|
||||
.map(normalize_uploader_name)
|
||||
.is_some_and(|value| value == normalized_uploader)
|
||||
}
|
||||
|
||||
fn add_inline_previews(video_items: &mut [VideoItem]) {
|
||||
for video in video_items.iter_mut() {
|
||||
if video.duration <= 120 {
|
||||
let mut preview_url = video.url.clone();
|
||||
if let Some(x) = &video.formats {
|
||||
if let Some(first) = x.first() {
|
||||
preview_url = first.url.clone();
|
||||
}
|
||||
}
|
||||
video.preview = Some(preview_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn slugify(value: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut prev_dash = false;
|
||||
|
||||
for ch in normalize_uploader_name(value).chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch);
|
||||
prev_dash = false;
|
||||
} else if !prev_dash {
|
||||
slug.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::resource("/status")
|
||||
@@ -141,19 +224,14 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
// .route(web::get().to(videos_get))
|
||||
.route(web::post().to(videos_post)),
|
||||
)
|
||||
.service(web::resource("/uploader").route(web::post().to(uploader_post)))
|
||||
.service(web::resource("/uploaders").route(web::post().to(uploader_post)))
|
||||
.service(web::resource("/test").route(web::get().to(test)))
|
||||
.service(web::resource("/proxies").route(web::get().to(proxies)));
|
||||
}
|
||||
|
||||
async fn status(req: HttpRequest) -> Result<impl web::Responder, web::Error> {
|
||||
let clientversion: ClientVersion = match req.headers().get("User-Agent") {
|
||||
Some(v) => match v.to_str() {
|
||||
Ok(useragent) => ClientVersion::parse(useragent)
|
||||
.unwrap_or_else(|| ClientVersion::new(999, 0, "Hot%20Tub".to_string())),
|
||||
Err(_) => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
|
||||
},
|
||||
_ => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
|
||||
};
|
||||
let clientversion = client_version_from_request(&req);
|
||||
|
||||
println!(
|
||||
"Received status request with client version: {:?}",
|
||||
@@ -198,35 +276,9 @@ async fn videos_post(
|
||||
requester: web::types::State<Requester>,
|
||||
req: HttpRequest,
|
||||
) -> Result<impl web::Responder, web::Error> {
|
||||
let clientversion: ClientVersion = match req.headers().get("User-Agent") {
|
||||
Some(v) => match v.to_str() {
|
||||
Ok(useragent) => ClientVersion::parse(useragent)
|
||||
.unwrap_or_else(|| ClientVersion::new(999, 0, "Hot%20Tub".to_string())),
|
||||
Err(_) => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
|
||||
},
|
||||
_ => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
|
||||
};
|
||||
let clientversion = client_version_from_request(&req);
|
||||
let requester = requester.get_ref().clone();
|
||||
// Ensure "videos" table exists with two string columns.
|
||||
match pool.get() {
|
||||
Ok(mut conn) => match db::has_table(&mut conn, "videos") {
|
||||
Ok(false) => {
|
||||
if let Err(e) = db::create_table(
|
||||
&mut conn,
|
||||
"CREATE TABLE videos (id TEXT NOT NULL, url TEXT NOT NULL);",
|
||||
) {
|
||||
report_provider_error("db", "videos_post.create_table", &e.to_string()).await;
|
||||
}
|
||||
}
|
||||
Ok(true) => {}
|
||||
Err(e) => {
|
||||
report_provider_error("db", "videos_post.has_table", &e.to_string()).await;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
report_provider_error("db", "videos_post.pool_get", &e.to_string()).await;
|
||||
}
|
||||
}
|
||||
ensure_videos_table(pool.get_ref()).await;
|
||||
|
||||
let mut videos = Videos {
|
||||
pageInfo: PageInfo {
|
||||
@@ -387,21 +439,182 @@ async fn videos_post(
|
||||
});
|
||||
//###
|
||||
|
||||
for video in videos.items.iter_mut() {
|
||||
if video.duration <= 120 {
|
||||
let mut preview_url = video.url.clone();
|
||||
if let Some(x) = &video.formats {
|
||||
if let Some(first) = x.first() {
|
||||
preview_url = first.url.clone();
|
||||
}
|
||||
}
|
||||
video.preview = Some(preview_url);
|
||||
}
|
||||
}
|
||||
add_inline_previews(&mut videos.items);
|
||||
|
||||
Ok(web::HttpResponse::Ok().json(&videos))
|
||||
}
|
||||
|
||||
async fn uploader_post(
|
||||
uploader_request: web::types::Json<UploaderRequest>,
|
||||
cache: web::types::State<VideoCache>,
|
||||
pool: web::types::State<DbPool>,
|
||||
requester: web::types::State<Requester>,
|
||||
req: HttpRequest,
|
||||
) -> Result<impl web::Responder, web::Error> {
|
||||
let clientversion = client_version_from_request(&req);
|
||||
let requester = requester.get_ref().clone();
|
||||
ensure_videos_table(pool.get_ref()).await;
|
||||
|
||||
let uploader_name = uploader_request
|
||||
.uploader
|
||||
.as_deref()
|
||||
.or(uploader_request.title.as_deref())
|
||||
.or(uploader_request.query.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| web::error::ErrorBadRequest("Missing uploader".to_string()))?;
|
||||
let normalized_uploader = normalize_uploader_name(uploader_name);
|
||||
if normalized_uploader.is_empty() {
|
||||
return Err(web::error::ErrorBadRequest("Missing uploader".to_string()).into());
|
||||
}
|
||||
|
||||
let page: u8 = uploader_request
|
||||
.page
|
||||
.as_ref()
|
||||
.and_then(|value| value.to_u8())
|
||||
.unwrap_or(1);
|
||||
let per_page: u8 = uploader_request
|
||||
.perPage
|
||||
.as_ref()
|
||||
.and_then(|value| value.to_u8())
|
||||
.unwrap_or(10);
|
||||
let sort = uploader_request
|
||||
.sort
|
||||
.as_deref()
|
||||
.unwrap_or("date")
|
||||
.to_string();
|
||||
let featured = uploader_request
|
||||
.featured
|
||||
.as_deref()
|
||||
.unwrap_or("all")
|
||||
.to_string();
|
||||
let category = uploader_request
|
||||
.category
|
||||
.as_deref()
|
||||
.unwrap_or("all")
|
||||
.to_string();
|
||||
let sites = uploader_request
|
||||
.all_provider_sites
|
||||
.as_deref()
|
||||
.or(uploader_request.sites.as_deref())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let filter = uploader_request
|
||||
.filter
|
||||
.as_deref()
|
||||
.unwrap_or("new")
|
||||
.to_string();
|
||||
let language = uploader_request
|
||||
.language
|
||||
.as_deref()
|
||||
.unwrap_or("en")
|
||||
.to_string();
|
||||
let networks = uploader_request
|
||||
.networks
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let stars = uploader_request.stars.as_deref().unwrap_or("").to_string();
|
||||
let categories = uploader_request
|
||||
.categories
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let duration = uploader_request
|
||||
.duration
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let sexuality = uploader_request
|
||||
.sexuality
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let public_url_base = format!(
|
||||
"{}://{}",
|
||||
req.connection_info().scheme(),
|
||||
req.connection_info().host()
|
||||
);
|
||||
let options = ServerOptions {
|
||||
featured: Some(featured),
|
||||
category: Some(category),
|
||||
sites: Some(sites),
|
||||
filter: Some(filter),
|
||||
language: Some(language),
|
||||
public_url_base: Some(public_url_base),
|
||||
requester: Some(requester),
|
||||
network: Some(networks),
|
||||
stars: Some(stars),
|
||||
categories: Some(categories),
|
||||
duration: Some(duration),
|
||||
sort: Some(sort.clone()),
|
||||
sexuality: Some(sexuality),
|
||||
};
|
||||
|
||||
let provider = get_provider("all")
|
||||
.ok_or_else(|| web::error::ErrorBadRequest("Invalid channel".to_string()))?;
|
||||
let mut video_items = run_provider_guarded(
|
||||
"all",
|
||||
"uploader_post.get_videos",
|
||||
provider.get_videos(
|
||||
cache.get_ref().clone(),
|
||||
pool.get_ref().clone(),
|
||||
sort,
|
||||
Some(uploader_name.to_string()),
|
||||
page.to_string(),
|
||||
per_page.to_string(),
|
||||
options,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
video_items.retain(|video| video_matches_normalized_uploader(video, &normalized_uploader));
|
||||
add_inline_previews(&mut video_items);
|
||||
|
||||
let display_name = video_items
|
||||
.iter()
|
||||
.find_map(|video| video.uploader.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| uploader_name.to_string());
|
||||
let row = LayoutRow {
|
||||
id: "videos".to_string(),
|
||||
row_type: "videos".to_string(),
|
||||
title: "Videos".to_string(),
|
||||
subtitle: Some(format!("Results for {display_name}")),
|
||||
pageInfo: PageInfo {
|
||||
hasNextPage: !video_items.is_empty(),
|
||||
resultsPerPage: u32::from(per_page),
|
||||
},
|
||||
items: video_items,
|
||||
};
|
||||
let response = UploaderResponse {
|
||||
id: slugify(&display_name),
|
||||
title: display_name.clone(),
|
||||
uploader: display_name,
|
||||
rows: vec![row],
|
||||
};
|
||||
|
||||
Ok(web::HttpResponse::Ok().json(&response))
|
||||
}
|
||||
|
||||
pub fn get_provider(channel: &str) -> Option<DynProvider> {
|
||||
ALL_PROVIDERS.get(channel).cloned()
|
||||
}
|
||||
@@ -443,3 +656,41 @@ pub async fn proxies() -> Result<impl web::Responder, web::Error> {
|
||||
}
|
||||
Ok(web::HttpResponse::Ok().json(&by_protocol))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{normalize_uploader_name, slugify, video_matches_normalized_uploader};
|
||||
use crate::videos::VideoItem;
|
||||
|
||||
#[test]
|
||||
fn normalize_uploader_name_collapses_spacing_and_case() {
|
||||
assert_eq!(
|
||||
normalize_uploader_name(" #The Pet Collective "),
|
||||
"the pet collective"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploader_match_uses_normalized_equality() {
|
||||
let video = VideoItem::new(
|
||||
"id".to_string(),
|
||||
"title".to_string(),
|
||||
"https://example.com/video".to_string(),
|
||||
"all".to_string(),
|
||||
"https://example.com/thumb.jpg".to_string(),
|
||||
90,
|
||||
)
|
||||
.uploader("The Pet Collective".to_string());
|
||||
|
||||
assert!(video_matches_normalized_uploader(
|
||||
&video,
|
||||
"the pet collective"
|
||||
));
|
||||
assert!(!video_matches_normalized_uploader(&video, "pet collective"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_uses_normalized_name() {
|
||||
assert_eq!(slugify(" #The Pet Collective "), "the-pet-collective");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ pub mod pmvhaven;
|
||||
pub mod pornhat;
|
||||
pub mod pornhub;
|
||||
pub mod redtube;
|
||||
pub mod rule34video;
|
||||
pub mod spankbang;
|
||||
// pub mod hentaimoon;
|
||||
pub mod beeg;
|
||||
@@ -34,6 +33,7 @@ pub mod omgxxx;
|
||||
pub mod paradisehill;
|
||||
pub mod porn00;
|
||||
pub mod porn4fans;
|
||||
pub mod porndish;
|
||||
pub mod pornzog;
|
||||
pub mod shooshtime;
|
||||
pub mod sxyprn;
|
||||
@@ -53,6 +53,7 @@ pub mod javtiful;
|
||||
pub mod noodlemagazine;
|
||||
pub mod pimpbunny;
|
||||
pub mod rule34gen;
|
||||
pub mod rule34video;
|
||||
pub mod xxdbx;
|
||||
// pub mod tube8;
|
||||
|
||||
@@ -78,10 +79,6 @@ pub static ALL_PROVIDERS: Lazy<HashMap<&'static str, DynProvider>> = Lazy::new(|
|
||||
"spankbang",
|
||||
Arc::new(spankbang::SpankbangProvider::new()) as DynProvider,
|
||||
);
|
||||
m.insert(
|
||||
"rule34video",
|
||||
Arc::new(rule34video::Rule34videoProvider::new()) as DynProvider,
|
||||
);
|
||||
m.insert(
|
||||
"redtube",
|
||||
Arc::new(redtube::RedtubeProvider::new()) as DynProvider,
|
||||
@@ -134,6 +131,10 @@ pub static ALL_PROVIDERS: Lazy<HashMap<&'static str, DynProvider>> = Lazy::new(|
|
||||
"porn4fans",
|
||||
Arc::new(porn4fans::Porn4fansProvider::new()) as DynProvider,
|
||||
);
|
||||
m.insert(
|
||||
"porndish",
|
||||
Arc::new(porndish::PorndishProvider::new()) as DynProvider,
|
||||
);
|
||||
m.insert(
|
||||
"shooshtime",
|
||||
Arc::new(shooshtime::ShooshtimeProvider::new()) as DynProvider,
|
||||
@@ -160,6 +161,10 @@ pub static ALL_PROVIDERS: Lazy<HashMap<&'static str, DynProvider>> = Lazy::new(|
|
||||
Arc::new(viralxxxporn::ViralxxxpornProvider::new()) as DynProvider,
|
||||
);
|
||||
// m.insert("pornxp", Arc::new(pornxp::PornxpProvider::new()) as DynProvider);
|
||||
m.insert(
|
||||
"rule34video",
|
||||
Arc::new(rule34video::Rule34videoProvider::new()) as DynProvider,
|
||||
);
|
||||
m.insert(
|
||||
"rule34gen",
|
||||
Arc::new(rule34gen::Rule34genProvider::new()) as DynProvider,
|
||||
|
||||
1066
src/providers/porndish.rs
Normal file
1066
src/providers/porndish.rs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ pub mod hanimecdn;
|
||||
pub mod hqpornerthumb;
|
||||
pub mod javtiful;
|
||||
pub mod noodlemagazine;
|
||||
pub mod porndishthumb;
|
||||
pub mod spankbang;
|
||||
pub mod sxyprn;
|
||||
|
||||
|
||||
62
src/proxies/porndishthumb.rs
Normal file
62
src/proxies/porndishthumb.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use ntex::http::header::CONTENT_TYPE;
|
||||
use ntex::{
|
||||
http::Response,
|
||||
web::{self, HttpRequest, error},
|
||||
};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
pub async fn get_image(
|
||||
req: HttpRequest,
|
||||
_requester: web::types::State<Requester>,
|
||||
) -> Result<impl web::Responder, web::Error> {
|
||||
let endpoint = req.match_info().query("endpoint").to_string();
|
||||
let image_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
endpoint
|
||||
} else {
|
||||
format!("https://{}", endpoint.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg(
|
||||
r#"
|
||||
import sys
|
||||
from curl_cffi import requests
|
||||
|
||||
url = sys.argv[1]
|
||||
response = requests.get(
|
||||
url,
|
||||
impersonate="chrome",
|
||||
timeout=30,
|
||||
allow_redirects=True,
|
||||
headers={"Referer": "https://www.porndish.com/"},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
sys.stderr.write(f"status={response.status_code}\n")
|
||||
sys.exit(1)
|
||||
sys.stderr.write(response.headers.get("content-type", "application/octet-stream"))
|
||||
sys.stdout.buffer.write(response.content)
|
||||
"#,
|
||||
)
|
||||
.arg(image_url)
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.map_err(error::ErrorBadGateway)?
|
||||
.map_err(error::ErrorBadGateway)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(web::HttpResponse::NotFound().finish());
|
||||
}
|
||||
|
||||
let content_type = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let mut resp = Response::build(ntex::http::StatusCode::OK);
|
||||
if !content_type.is_empty() {
|
||||
resp.set_header(CONTENT_TYPE, content_type);
|
||||
}
|
||||
|
||||
Ok(resp.body(output.stdout))
|
||||
}
|
||||
@@ -36,6 +36,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
web::resource("/hqporner-thumb/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::hqpornerthumb::get_image))
|
||||
.route(web::get().to(crate::proxies::hqpornerthumb::get_image)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/porndish-thumb/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::porndishthumb::get_image))
|
||||
.route(web::get().to(crate::proxies::porndishthumb::get_image)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,30 @@ pub struct VideosRequest {
|
||||
pub duration: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub struct UploaderRequest {
|
||||
pub uploader: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub uploaderUrl: Option<String>,
|
||||
pub uploaderId: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
pub query: Option<String>,
|
||||
pub page: Option<FlexibleNumber>,
|
||||
pub perPage: Option<FlexibleNumber>,
|
||||
pub featured: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub sites: Option<String>,
|
||||
pub all_provider_sites: Option<String>,
|
||||
pub filter: Option<String>,
|
||||
pub language: Option<String>,
|
||||
pub networks: Option<String>,
|
||||
pub stars: Option<String>,
|
||||
pub categories: Option<String>,
|
||||
pub duration: Option<String>,
|
||||
pub sexuality: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
pub struct ServerOptions {
|
||||
pub featured: Option<String>, // "featured",
|
||||
@@ -405,3 +429,23 @@ pub struct Videos {
|
||||
pub pageInfo: PageInfo,
|
||||
pub items: Vec<VideoItem>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub struct LayoutRow {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub row_type: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subtitle: Option<String>,
|
||||
pub pageInfo: PageInfo,
|
||||
pub items: Vec<VideoItem>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub struct UploaderResponse {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub uploader: String,
|
||||
pub rows: Vec<LayoutRow>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user