From c97c6b94698613b49510aed800b69d28f29e118f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:14:09 +0200 Subject: [PATCH] Port Doodstream and VOE uploaders to Rust - Doodstream: login_ajax + sess_id scrape from /?op=upload page + upload_server + multipart upload + XFS-style fn field + filecode extraction. Skips OTP path (v1 still has the full flow). - VOE: login page CSRF scrape + POST /login + fresh CSRF from /file-upload + /engine/delivery-node for CDN server + baseline my-files snapshot + multipart upload + file-list polling fallback when response is empty. Both wire into the existing dispatcher (hosters::upload_file) and pick up the same rotation/classifier layer as the other uploaders. Release build clean: exe 7.0 MB, NSIS 2.5 MB, MSI 3.4 MB. --- README.md | 12 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/hosters/doodstream.rs | 227 ++++++++++++++++++++++- src-tauri/src/hosters/voe.rs | 277 +++++++++++++++++++++++++++- 5 files changed, 493 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index aa57d46..e9e643d 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ Multi-Hoster-Upload-2.0/ │ │ ├─ clouddrop.rs ✔ Full port (simple + chunked) │ │ ├─ byse.rs ✔ Full port (XFS + file-list polling) │ │ ├─ vidmoly.rs ✔ Full port (new SPA auth + transit server) -│ │ ├─ doodstream.rs ⚠ Stub — run v1 until ported -│ │ └─ voe.rs ⚠ Stub — run v1 until ported +│ │ ├─ doodstream.rs ✔ Login + sess_id scrape + XFS upload (no OTP path yet) +│ │ └─ voe.rs ✔ Login + CSRF + delivery-node + file-list polling │ ├─ upload_manager.rs Batch orchestrator │ └─ commands.rs #[tauri::command] IPC handlers └─ README.md @@ -76,8 +76,8 @@ Multi-Hoster-Upload-2.0/ | Clouddrop uploader | ✅ simple + chunked (upload.clouddrop.cc) | | Byse uploader | ✅ includes file-list polling for empty-filecode case | | Vidmoly uploader | ✅ new `/api/auth/login` + `/api/upload/config` + X-Progress-ID | -| Doodstream uploader | ⚠ stub (see port TODO) | -| VOE uploader | ⚠ stub (see port TODO) | +| Doodstream uploader | ✅ login_ajax + sess_id scrape + multipart upload (OTP flow TODO) | +| VOE uploader | ✅ Laravel login + CSRF + delivery-node + my-files polling | | Queue persistence | ⚠ not yet — restart starts empty | | Folder monitor | ⚠ not yet | | Remote-control server | ⚠ not yet | @@ -107,8 +107,8 @@ cargo tauri build It's separate from v1's `electron-config.json` so both versions can coexist. - To migrate: in v1 use *Export Backup*, in v2 use *Import Backup*. Both speak the same .mhu format. -- Doodstream & VOE still require v1 until their web-scraping is ported — the - Rust scaffolding for them is in place, just needs the login/CSRF logic. +- All 5 hosters are ported to Rust. Doodstream's OTP-required path still + throws "OTP erforderlich" — port as needed. ## Why Tauri over Electron diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ab6dab5..5446d49 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2489,6 +2489,7 @@ dependencies = [ "scraper", "serde", "serde_json", + "serde_urlencoded", "serde_with", "sha2", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 566dd76..75e7e9b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,6 +50,7 @@ bytes = "1" mime_guess = "2" urlencoding = "2" percent-encoding = "2" +serde_urlencoded = "0.7" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/src-tauri/src/hosters/doodstream.rs b/src-tauri/src/hosters/doodstream.rs index f1bff00..5479bdb 100644 --- a/src-tauri/src/hosters/doodstream.rs +++ b/src-tauri/src/hosters/doodstream.rs @@ -1,17 +1,224 @@ -//! Doodstream.com uploader — port of `lib/doodstream-upload.js` (TODO). +//! Doodstream.com uploader. Port of `lib/doodstream-upload.js`. //! -//! Complex scraper: login via web form, parse CSRF from HTML, multipart upload -//! to a transit server resolved from the HTML. Ships as a stub in 2.0 POC — -//! the v1 Electron implementation keeps shipping alongside until the port -//! is complete. +//! Flow (simplified — no OTP path in this revision): +//! 1. POST / op=login_ajax&login=…&password=… → sets session cookies +//! 2. GET /?op=upload → scrape `sess_id` from HTML +//! 3. GET /?op=upload_server → { result: "https://srv/upload/01" } +//! 4. POST → multipart sess_id+utype=reg+file +//! 5. Response may be JSON or a filecode-bearing HTML; extract filecode. use super::{UploadCtx, UploadTask}; use crate::error::{AppError, AppResult}; use crate::events::UploadResult; -pub async fn upload(_task: UploadTask, _ctx: UploadCtx) -> AppResult { - Err(AppError::Other( - "Doodstream-Uploader in 2.0 noch nicht portiert. Nutze bis dahin v1." - .into(), - )) +use bytes::Bytes; +use once_cell::sync::Lazy; +use regex::Regex; +use reqwest::{multipart, Body, Client}; +use std::time::Duration; +use tokio::fs::File; +use tokio_util::io::ReaderStream; + +const BASE_URL: &str = "https://doodstream.com"; +const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +fn build_client() -> AppResult { + Client::builder() + .timeout(Duration::from_secs(30 * 60)) + .connect_timeout(Duration::from_secs(60)) + .cookie_store(true) + .redirect(reqwest::redirect::Policy::limited(10)) + .gzip(true) + .user_agent(UA) + .build() + .map_err(AppError::from) +} + +pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult { + if task.username.is_empty() || task.password.is_empty() { + return Err(AppError::BadCredentials); + } + let c = build_client()?; + + // --- Login --- + let _ = c.get(BASE_URL).send().await; // warm up + + let login_body = serde_urlencoded::to_string([ + ("op", "login_ajax"), + ("login", task.username.as_str()), + ("password", task.password.as_str()), + ("loginotp", ""), + ]).unwrap_or_default(); + + let res = c.post(format!("{BASE_URL}/")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Referer", format!("{BASE_URL}/")) + .header("X-Requested-With", "XMLHttpRequest") + .body(login_body) + .send() + .await?; + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + + // Redirect response (3xx auto-followed by client) or dashboard hit → login OK. + // Otherwise try JSON shape. + if !body.contains("Dashboard") { + if let Ok(v) = serde_json::from_str::(&body) { + if let Some(s) = v.get("status").and_then(|x| x.as_str()) { + if s == "fail" { + let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("Login fehlgeschlagen"); + if msg.to_lowercase().contains("otp") { + return Err(AppError::Other(format!("Doodstream: OTP erforderlich ({msg}) — OTP-Login in 2.0 POC noch nicht implementiert"))); + } + return Err(AppError::BadCredentials); + } + } + } else if !status.is_redirection() && !status.is_success() { + return Err(AppError::BadCredentials); + } + } + + // --- Extract sess_id from /?op=upload --- + let upload_page = c.get(format!("{BASE_URL}/?op=upload")) + .header("Referer", format!("{BASE_URL}/")).send().await? + .text().await.unwrap_or_default(); + let sess_id = extract_sess_id(&upload_page) + .ok_or_else(|| AppError::BadResponse("Doodstream: sess_id nicht gefunden".into()))?; + + // --- Get upload server --- + let srv_json = c.get(format!("{BASE_URL}/?op=upload_server")) + .header("Referer", format!("{BASE_URL}/?op=upload")).send().await? + .text().await.unwrap_or_default(); + let server_url = match serde_json::from_str::(&srv_json) { + Ok(v) => v.get("result").and_then(|x| x.as_str()).map(|s| s.to_string()), + Err(_) => None, + }.or_else(|| SRV_RE.captures(&upload_page).and_then(|c| c.get(1).map(|m| m.as_str().to_string()))) + .ok_or_else(|| AppError::BadResponse("Doodstream: Upload-Server nicht erhalten".into()))?; + + // --- POST multipart --- + let path = task.file_path.as_path(); + let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + let file_size = tokio::fs::metadata(path).await?.len(); + let file = File::open(path).await?; + let stream = progress_stream(file, file_size, ctx.clone()); + let part = multipart::Part::stream_with_length(Body::wrap_stream(stream), file_size) + .file_name(file_name.clone()) + .mime_str("application/octet-stream") + .map_err(|e| AppError::Other(format!("MIME: {e}")))?; + let form = multipart::Form::new() + .text("sess_id", sess_id) + .text("utype", "reg") + .part("file", part); + + let up = c.post(&server_url) + .header("Referer", format!("{BASE_URL}/?op=upload")) + .multipart(form) + .send().await?; + let up_status = up.status(); + let raw = up.text().await.unwrap_or_default(); + + if up_status.is_client_error() || up_status.is_server_error() { + return Err(AppError::HosterError("Doodstream".into(), + format!("Upload HTTP {}: {}", up_status.as_u16(), &raw[..raw.len().min(200)]))); + } + + parse_upload_response(&raw, &file_name, &c).await +} + +static SESS_RE_INPUT: Lazy = Lazy::new(|| + Regex::new(r#"name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap()); +static SESS_RE_VUE: Lazy = Lazy::new(|| + Regex::new(r#"sess_id['"\s:]+['"]([a-zA-Z0-9]+)['"]"#).unwrap()); +static SRV_RE: Lazy = Lazy::new(|| + Regex::new(r#"srv_url['"\s:]+['"]?(https?://[^'">\s]+)"#).unwrap()); +static FN_RE: Lazy = Lazy::new(|| + Regex::new(r#"name=["']fn["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap()); +static DL_RE: Lazy = Lazy::new(|| + Regex::new(r#"https?://[a-z0-9.]+/d/([a-zA-Z0-9]+)"#).unwrap()); + +fn extract_sess_id(html: &str) -> Option { + SESS_RE_INPUT.captures(html).or_else(|| SESS_RE_VUE.captures(html)) + .and_then(|c| c.get(1).map(|m| m.as_str().to_string())) +} + +async fn parse_upload_response(raw: &str, _file_name: &str, client: &Client) -> AppResult { + // JSON shape? + if let Ok(v) = serde_json::from_str::(raw) { + if let Some(code) = v.get("file_code").and_then(|x| x.as_str()) + .or_else(|| v.pointer("/files/0/filecode").and_then(|x| x.as_str())) + .or_else(|| v.pointer("/result/0/filecode").and_then(|x| x.as_str())) + { + if !code.is_empty() { + return Ok(build_result(code)); + } + } + } + + // XFS-style: hidden input name="fn" holds the filecode. Submit op=upload_result + // to complete, then parse the follow-up for download URL. + if let Some(cap) = FN_RE.captures(raw) { + let fn_code = cap.get(1).unwrap().as_str(); + // Submit upload_result + let body = serde_urlencoded::to_string([ + ("op", "upload_result"), + ("fn", fn_code), + ]).unwrap_or_default(); + if let Ok(follow) = client.post(format!("{BASE_URL}/")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Referer", format!("{BASE_URL}/")) + .body(body).send().await + { + let follow_text = follow.text().await.unwrap_or_default(); + if let Some(c) = DL_RE.captures(&follow_text) { + if let Some(m) = c.get(1) { return Ok(build_result(m.as_str())); } + } + } + if fn_code.len() >= 8 { return Ok(build_result(fn_code)); } + } + + if let Some(c) = DL_RE.captures(raw) { + if let Some(m) = c.get(1) { return Ok(build_result(m.as_str())); } + } + + Err(AppError::BadResponse(format!( + "Doodstream: Upload-Antwort ohne filecode ({}))", + &raw[..raw.len().min(240)] + ))) +} + +fn build_result(code: &str) -> UploadResult { + UploadResult { + download_url: Some(format!("https://dsvplay.com/d/{code}")), + embed_url: Some(format!("https://dsvplay.com/e/{code}")), + file_code: Some(code.to_string()), + } +} + +fn progress_stream( + file: File, + total: u64, + ctx: UploadCtx, +) -> impl futures::Stream> + Send + 'static { + use futures::StreamExt; + let ctx1 = ctx.clone(); + let ctx2 = ctx; + let mut acc: u64 = 0; + ReaderStream::with_capacity(file, 256 * 1024).then(move |chunk| { + let ctx_in = ctx1.clone(); + let ctx_pr = ctx2.clone(); + async move { + match chunk { + Ok(b) => { + if ctx_in.is_aborted() { + return Err(std::io::Error::new(std::io::ErrorKind::Other, "Aborted")); + } + ctx_in.throttle(b.len() as u64).await; + acc += b.len() as u64; + (ctx_pr.on_progress)(acc, total); + Ok(b) + } + Err(e) => Err(e), + } + } + }) } diff --git a/src-tauri/src/hosters/voe.rs b/src-tauri/src/hosters/voe.rs index 5e27ad7..dfbfc24 100644 --- a/src-tauri/src/hosters/voe.rs +++ b/src-tauri/src/hosters/voe.rs @@ -1,16 +1,275 @@ -//! VOE.sx uploader — port of `lib/voe-upload.js` (TODO). +//! VOE.sx uploader. Port of `lib/voe-upload.js`. //! -//! VOE uses web login + CSRF scrape + session, plus CDN-fronted upload server -//! negotiation. The SPA redesign is still in flux; porting this properly is -//! follow-up work to 2.0's initial shipping scope. +//! Flow: +//! 1. GET /login → scrape CSRF token (meta or hidden input) +//! 2. POST /login _token + email + password → laravel session cookie +//! 3. GET /file-upload → fresh CSRF for upload +//! 4. GET /engine/delivery-node (X-CSRF) → { server, session_id } +//! 5. Snapshot /api2/my-files (to identify new uploads later) +//! 6. POST multipart session_id+file → may return JSON w/ file_code +//! OR empty → poll my-files until a new file_code shows up. use super::{UploadCtx, UploadTask}; use crate::error::{AppError, AppResult}; use crate::events::UploadResult; -pub async fn upload(_task: UploadTask, _ctx: UploadCtx) -> AppResult { - Err(AppError::Other( - "VOE-Uploader in 2.0 noch nicht portiert. Nutze bis dahin v1." - .into(), - )) +use bytes::Bytes; +use once_cell::sync::Lazy; +use regex::Regex; +use reqwest::{multipart, Body, Client}; +use std::collections::HashSet; +use std::time::Duration; +use tokio::fs::File; +use tokio_util::io::ReaderStream; + +const BASE_URL: &str = "https://voe.sx"; +const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +fn build_client() -> AppResult { + Client::builder() + .timeout(Duration::from_secs(30 * 60)) + .connect_timeout(Duration::from_secs(60)) + .cookie_store(true) + .redirect(reqwest::redirect::Policy::limited(10)) + .gzip(true) + .user_agent(UA) + .build() + .map_err(AppError::from) +} + +pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult { + if task.username.is_empty() || task.password.is_empty() { + return Err(AppError::BadCredentials); + } + + let c = build_client()?; + + // --- Login --- + let login_html = c.get(format!("{BASE_URL}/login")) + .send().await?.text().await.unwrap_or_default(); + let csrf = extract_csrf(&login_html) + .ok_or_else(|| AppError::HosterError("VOE".into(), "CSRF-Token nicht gefunden".into()))?; + let body = serde_urlencoded::to_string([ + ("_token", csrf.as_str()), + ("email", task.username.as_str()), + ("password", task.password.as_str()), + ]).unwrap_or_default(); + let res = c.post(format!("{BASE_URL}/login")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Referer", format!("{BASE_URL}/login")) + .body(body).send().await?; + let body = res.text().await.unwrap_or_default(); + if body.contains("credentials do not match") || body.contains("Incorrect") || body.contains("invalid") { + return Err(AppError::BadCredentials); + } + + // --- Fresh CSRF from upload page --- + let upload_html = c.get(format!("{BASE_URL}/file-upload")) + .send().await?.text().await.unwrap_or_default(); + let csrf_upload = extract_csrf(&upload_html).ok_or_else(|| + AppError::HosterError("VOE".into(), + "CSRF-Token nicht gefunden. Bist du eingeloggt?".into()))?; + + // --- Delivery node --- + let dn_body = c.get(format!("{BASE_URL}/engine/delivery-node")) + .header("X-CSRF-TOKEN", &csrf_upload) + .header("X-Requested-With", "XMLHttpRequest") + .header("Accept", "application/json") + .send().await?.text().await.unwrap_or_default(); + let dn: serde_json::Value = serde_json::from_str(&dn_body).map_err(|_| + AppError::BadResponse(format!("VOE: delivery-node kein JSON: {}", + &dn_body[..dn_body.len().min(200)])))?; + let upload_server = dn.get("server").and_then(|x| x.as_str()) + .filter(|_| dn.get("success").and_then(|x| x.as_bool()).unwrap_or(false)) + .ok_or_else(|| AppError::HosterError("VOE".into(), + "Kein Upload-Server erhalten von delivery-node".into()))? + .to_string(); + let session_id = dn.get("session_id").and_then(|x| x.as_str()).unwrap_or("").to_string(); + + // --- Baseline file list --- + let baseline = fetch_file_codes(&c).await.unwrap_or_default(); + + // --- POST multipart --- + let path = task.file_path.as_path(); + let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + let file_size = tokio::fs::metadata(path).await?.len(); + let file = File::open(path).await?; + let stream = progress_stream(file, file_size, ctx.clone()); + let part = multipart::Part::stream_with_length(Body::wrap_stream(stream), file_size) + .file_name(file_name.clone()) + .mime_str("application/octet-stream") + .map_err(|e| AppError::Other(format!("MIME: {e}")))?; + let mut form = multipart::Form::new().part("file", part); + if !session_id.is_empty() { + form = form.text("session_id", session_id); + } + + let resp = c.post(&upload_server) + .header("X-CSRF-TOKEN", &csrf_upload) + .header("X-Requested-With", "XMLHttpRequest") + .header("Origin", BASE_URL) + .header("Referer", format!("{BASE_URL}/file-upload")) + .multipart(form).send().await?; + let status = resp.status(); + let raw = resp.text().await.unwrap_or_default(); + + if status.is_client_error() || status.is_server_error() { + return Err(AppError::HosterError("VOE".into(), + format!("Upload HTTP {}: {}", status.as_u16(), &raw[..raw.len().min(200)]))); + } + + // Try JSON shape first. + if let Ok(v) = serde_json::from_str::(&raw) { + let code = extract_code_from_value(&v); + if let Some(c) = code { + return Ok(build_urls(&c)); + } + if let Some(msg) = v.get("error").and_then(|x| x.as_str()).or_else(|| v.get("message").and_then(|x| x.as_str())) { + return Err(AppError::HosterError("VOE".into(), msg.to_string())); + } + } + + // Fallback: poll my-files. + if let Some(found) = poll_for_upload(&c, &file_name, &baseline, &ctx).await { + return Ok(found); + } + + Err(AppError::BadResponse(format!( + "VOE Upload: kein file_code in der Antwort ({})", + &raw[..raw.len().min(200)] + ))) +} + +static CSRF_META: Lazy = Lazy::new(|| + Regex::new(r#" = Lazy::new(|| + Regex::new(r#"]*name=["']_token["'][^>]*value=["']([^"']+)["']"#).unwrap()); +static CSRF_INPUT_2: Lazy = Lazy::new(|| + Regex::new(r#"]*value=["']([^"']+)["'][^>]*name=["']_token["']"#).unwrap()); + +fn extract_csrf(html: &str) -> Option { + CSRF_META.captures(html) + .or_else(|| CSRF_INPUT_1.captures(html)) + .or_else(|| CSRF_INPUT_2.captures(html)) + .and_then(|c| c.get(1).map(|m| m.as_str().to_string())) +} + +fn extract_code_from_value(v: &serde_json::Value) -> Option { + let keys = ["file_code", "filecode", "slug"]; + for k in keys { + if let Some(s) = v.get(k).and_then(|x| x.as_str()) { + if !s.is_empty() { return Some(s.to_string()); } + } + if let Some(s) = v.pointer(&format!("/file/{k}")).and_then(|x| x.as_str()) { + if !s.is_empty() { return Some(s.to_string()); } + } + if let Some(s) = v.pointer(&format!("/data/{k}")).and_then(|x| x.as_str()) { + if !s.is_empty() { return Some(s.to_string()); } + } + } + None +} + +async fn fetch_file_codes(c: &Client) -> AppResult> { + let body = c.get(format!("{BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50")) + .header("Accept", "application/json") + .send().await?.text().await.unwrap_or_default(); + let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null); + let arr = v.get("data").and_then(|x| x.as_array()).cloned() + .or_else(|| v.get("files").and_then(|x| x.as_array()).cloned()) + .unwrap_or_default(); + let mut out = HashSet::new(); + for f in arr { + if let Some(c) = f.get("file_code").and_then(|x| x.as_str()) + .or_else(|| f.get("slug").and_then(|x| x.as_str())) { + if !c.is_empty() { out.insert(c.to_string()); } + } + } + Ok(out) +} + +fn normalize(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { if c.is_ascii_alphanumeric() { out.push(c.to_ascii_lowercase()); } } + out +} + +async fn poll_for_upload(c: &Client, file_name: &str, baseline: &HashSet, ctx: &UploadCtx) + -> Option +{ + let expected = { + let stripped = std::path::Path::new(file_name).file_stem() + .and_then(|s| s.to_str()).unwrap_or(file_name); + normalize(stripped) + }; + for _ in 0..10 { + if ctx.is_aborted() { return None; } + let body = match c.get(format!("{BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50")) + .header("Accept", "application/json").send().await { + Ok(r) => r.text().await.unwrap_or_default(), + Err(_) => { tokio::time::sleep(Duration::from_secs(2)).await; continue; } + }; + let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null); + let arr = v.get("data").and_then(|x| x.as_array()).cloned() + .or_else(|| v.get("files").and_then(|x| x.as_array()).cloned()) + .unwrap_or_default(); + let mut best: Option<(i32, String)> = None; + for f in &arr { + let code = match f.get("file_code").and_then(|x| x.as_str()) + .or_else(|| f.get("slug").and_then(|x| x.as_str())) { + Some(c) if !c.is_empty() => c.to_string(), _ => continue, + }; + if baseline.contains(&code) { continue; } + let title = normalize(f.get("title").and_then(|x| x.as_str()) + .or_else(|| f.get("name").and_then(|x| x.as_str())) + .unwrap_or("")); + let score = if title == expected { 120 } + else if title.starts_with(&expected) || expected.starts_with(&title) { 90 } + else if title.contains(&expected) || expected.contains(&title) { 70 } + else { 10 }; + if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) { + best = Some((score, code)); + } + } + if let Some((_, code)) = best { return Some(build_urls(&code)); } + tokio::time::sleep(Duration::from_secs(2)).await; + } + None +} + +fn build_urls(code: &str) -> UploadResult { + UploadResult { + download_url: Some(format!("{BASE_URL}/{code}")), + embed_url: Some(format!("{BASE_URL}/e/{code}")), + file_code: Some(code.to_string()), + } +} + +fn progress_stream( + file: File, + total: u64, + ctx: UploadCtx, +) -> impl futures::Stream> + Send + 'static { + use futures::StreamExt; + let ctx1 = ctx.clone(); + let ctx2 = ctx; + let mut acc: u64 = 0; + ReaderStream::with_capacity(file, 256 * 1024).then(move |chunk| { + let ctx_in = ctx1.clone(); + let ctx_pr = ctx2.clone(); + async move { + match chunk { + Ok(b) => { + if ctx_in.is_aborted() { + return Err(std::io::Error::new(std::io::ErrorKind::Other, "Aborted")); + } + ctx_in.throttle(b.len() as u64).await; + acc += b.len() as u64; + (ctx_pr.on_progress)(acc, total); + Ok(b) + } + Err(e) => Err(e), + } + } + }) }