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.
This commit is contained in:
Claude 2026-04-20 17:14:09 +02:00
parent 8627a8e694
commit c97c6b9469
5 changed files with 493 additions and 25 deletions

View File

@ -49,8 +49,8 @@ Multi-Hoster-Upload-2.0/
│ │ ├─ clouddrop.rs ✔ Full port (simple + chunked) │ │ ├─ clouddrop.rs ✔ Full port (simple + chunked)
│ │ ├─ byse.rs ✔ Full port (XFS + file-list polling) │ │ ├─ byse.rs ✔ Full port (XFS + file-list polling)
│ │ ├─ vidmoly.rs ✔ Full port (new SPA auth + transit server) │ │ ├─ vidmoly.rs ✔ Full port (new SPA auth + transit server)
│ │ ├─ doodstream.rs ⚠ Stub — run v1 until ported │ │ ├─ doodstream.rs ✔ Login + sess_id scrape + XFS upload (no OTP path yet)
│ │ └─ voe.rs ⚠ Stub — run v1 until ported │ │ └─ voe.rs ✔ Login + CSRF + delivery-node + file-list polling
│ ├─ upload_manager.rs Batch orchestrator │ ├─ upload_manager.rs Batch orchestrator
│ └─ commands.rs #[tauri::command] IPC handlers │ └─ commands.rs #[tauri::command] IPC handlers
└─ README.md └─ README.md
@ -76,8 +76,8 @@ Multi-Hoster-Upload-2.0/
| Clouddrop uploader | ✅ simple + chunked (upload.clouddrop.cc) | | Clouddrop uploader | ✅ simple + chunked (upload.clouddrop.cc) |
| Byse uploader | ✅ includes file-list polling for empty-filecode case | | Byse uploader | ✅ includes file-list polling for empty-filecode case |
| Vidmoly uploader | ✅ new `/api/auth/login` + `/api/upload/config` + X-Progress-ID | | Vidmoly uploader | ✅ new `/api/auth/login` + `/api/upload/config` + X-Progress-ID |
| Doodstream uploader | ⚠ stub (see port TODO) | | Doodstream uploader | ✅ login_ajax + sess_id scrape + multipart upload (OTP flow TODO) |
| VOE uploader | ⚠ stub (see port TODO) | | VOE uploader | ✅ Laravel login + CSRF + delivery-node + my-files polling |
| Queue persistence | ⚠ not yet — restart starts empty | | Queue persistence | ⚠ not yet — restart starts empty |
| Folder monitor | ⚠ not yet | | Folder monitor | ⚠ not yet |
| Remote-control server | ⚠ 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. 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 - To migrate: in v1 use *Export Backup*, in v2 use *Import Backup*. Both speak the
same .mhu format. same .mhu format.
- Doodstream & VOE still require v1 until their web-scraping is ported — the - All 5 hosters are ported to Rust. Doodstream's OTP-required path still
Rust scaffolding for them is in place, just needs the login/CSRF logic. throws "OTP erforderlich" — port as needed.
## Why Tauri over Electron ## Why Tauri over Electron

1
src-tauri/Cargo.lock generated
View File

@ -2489,6 +2489,7 @@ dependencies = [
"scraper", "scraper",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded",
"serde_with", "serde_with",
"sha2", "sha2",
"tauri", "tauri",

View File

@ -50,6 +50,7 @@ bytes = "1"
mime_guess = "2" mime_guess = "2"
urlencoding = "2" urlencoding = "2"
percent-encoding = "2" percent-encoding = "2"
serde_urlencoded = "0.7"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }

View File

@ -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 //! Flow (simplified — no OTP path in this revision):
//! to a transit server resolved from the HTML. Ships as a stub in 2.0 POC — //! 1. POST / op=login_ajax&login=…&password=… → sets session cookies
//! the v1 Electron implementation keeps shipping alongside until the port //! 2. GET /?op=upload → scrape `sess_id` from HTML
//! is complete. //! 3. GET /?op=upload_server → { result: "https://srv/upload/01" }
//! 4. POST <server> → multipart sess_id+utype=reg+file
//! 5. Response may be JSON or a filecode-bearing HTML; extract filecode.
use super::{UploadCtx, UploadTask}; use super::{UploadCtx, UploadTask};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::events::UploadResult; use crate::events::UploadResult;
pub async fn upload(_task: UploadTask, _ctx: UploadCtx) -> AppResult<UploadResult> { use bytes::Bytes;
Err(AppError::Other( use once_cell::sync::Lazy;
"Doodstream-Uploader in 2.0 noch nicht portiert. Nutze bis dahin v1." use regex::Regex;
.into(), 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> {
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<UploadResult> {
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::<serde_json::Value>(&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::<serde_json::Value>(&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<Regex> = Lazy::new(||
Regex::new(r#"name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap());
static SESS_RE_VUE: Lazy<Regex> = Lazy::new(||
Regex::new(r#"sess_id['"\s:]+['"]([a-zA-Z0-9]+)['"]"#).unwrap());
static SRV_RE: Lazy<Regex> = Lazy::new(||
Regex::new(r#"srv_url['"\s:]+['"]?(https?://[^'">\s]+)"#).unwrap());
static FN_RE: Lazy<Regex> = Lazy::new(||
Regex::new(r#"name=["']fn["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap());
static DL_RE: Lazy<Regex> = Lazy::new(||
Regex::new(r#"https?://[a-z0-9.]+/d/([a-zA-Z0-9]+)"#).unwrap());
fn extract_sess_id(html: &str) -> Option<String> {
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<UploadResult> {
// JSON shape?
if let Ok(v) = serde_json::from_str::<serde_json::Value>(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<Item = Result<Bytes, std::io::Error>> + 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),
}
}
})
} }

View File

@ -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 //! Flow:
//! negotiation. The SPA redesign is still in flux; porting this properly is //! 1. GET /login → scrape CSRF token (meta or hidden input)
//! follow-up work to 2.0's initial shipping scope. //! 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 <server> 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 super::{UploadCtx, UploadTask};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::events::UploadResult; use crate::events::UploadResult;
pub async fn upload(_task: UploadTask, _ctx: UploadCtx) -> AppResult<UploadResult> { use bytes::Bytes;
Err(AppError::Other( use once_cell::sync::Lazy;
"VOE-Uploader in 2.0 noch nicht portiert. Nutze bis dahin v1." use regex::Regex;
.into(), 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> {
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<UploadResult> {
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::<serde_json::Value>(&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<Regex> = Lazy::new(||
Regex::new(r#"<meta\s+name=["']csrf-token["']\s+content=["']([^"']+)["']"#).unwrap());
static CSRF_INPUT_1: Lazy<Regex> = Lazy::new(||
Regex::new(r#"<input[^>]*name=["']_token["'][^>]*value=["']([^"']+)["']"#).unwrap());
static CSRF_INPUT_2: Lazy<Regex> = Lazy::new(||
Regex::new(r#"<input[^>]*value=["']([^"']+)["'][^>]*name=["']_token["']"#).unwrap());
fn extract_csrf(html: &str) -> Option<String> {
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<String> {
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<HashSet<String>> {
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<String>, ctx: &UploadCtx)
-> Option<UploadResult>
{
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<Item = Result<Bytes, std::io::Error>> + 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),
}
}
})
} }