Tauri 2 / Rust rewrite — initial 2.0 scaffold
Working: - Core: config, secret encryption, events, throttle - Upload manager with full rotation/classifier parity to v1 - Clouddrop uploader (simple + chunked upload.clouddrop.cc) - Byse uploader with file-list polling for empty-filecode case - Vidmoly uploader (new /api/auth/login + /api/upload/config + X-Progress-ID) - Minimal frontend (accounts, settings, upload table, rotation log) - Release build: exe 6.9 MB, NSIS installer 2.5 MB, MSI 3.4 MB Stubs (return 'not yet ported' error): - Doodstream (web login + CSRF — v1 scraper needs careful port) - VOE (web login + CSRF + delivery-node negotiation) Not yet migrated from v1: - Queue persistence on restart - Folder monitor - Remote-control server - Drop-target floating window - Auto-updater
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
//! Byse.sx uploader. Port of the generic XFS flow in `lib/hosters.js`.
|
||||
//!
|
||||
//! Steps:
|
||||
//! 1. GET https://api.byse.sx/upload/server?key=API_KEY → { result: "https://srv.../upload.cgi" }
|
||||
//! 2. Snapshot file list so we can identify the new upload even if filecode
|
||||
//! comes back empty (Byse sometimes replies with msg=OK + filecode="" but
|
||||
//! the file lands on the server anyway and gets its code async).
|
||||
//! 3. POST multipart to the returned server with form field `key=API_KEY`.
|
||||
//! 4. Parse JSON → if files[0].filecode is set, done. Otherwise poll file list
|
||||
//! up to 30s for a new filecode that matches the uploaded filename.
|
||||
|
||||
use super::{UploadCtx, UploadTask};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::events::UploadResult;
|
||||
|
||||
use bytes::Bytes;
|
||||
use reqwest::{multipart, Body, Client};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tokio::fs::File;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
const API_BASE: &str = "https://api.byse.sx";
|
||||
const DOWNLOAD_BASE: &str = "https://byse.sx";
|
||||
|
||||
fn client() -> AppResult<Client> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(30 * 60))
|
||||
.connect_timeout(Duration::from_secs(60))
|
||||
.pool_max_idle_per_host(20)
|
||||
.gzip(true)
|
||||
.user_agent("multi-hoster-uploader/2.0")
|
||||
.build()
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult<UploadResult> {
|
||||
let key = task.api_key.trim();
|
||||
if key.is_empty() { return Err(AppError::BadCredentials); }
|
||||
let path = task.file_path.as_path();
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
let file_size = meta.len();
|
||||
|
||||
let c = client()?;
|
||||
|
||||
// Baseline: which file_codes does the account already have?
|
||||
let baseline = fetch_file_list(&c, key).await.unwrap_or_default();
|
||||
let baseline_set: std::collections::HashSet<String> =
|
||||
baseline.iter().map(|f| f.file_code.clone()).collect();
|
||||
|
||||
// Get upload server URL.
|
||||
let server_url = get_upload_server(&c, key).await?;
|
||||
|
||||
// POST multipart.
|
||||
let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let upload_url = append_query(&server_url, "key", key);
|
||||
|
||||
let file = File::open(path).await?;
|
||||
let stream = progress_stream(file, file_size, ctx.clone());
|
||||
let body = Body::wrap_stream(stream);
|
||||
let part = multipart::Part::stream_with_length(body, 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("key", key.to_string())
|
||||
.part("file", part);
|
||||
|
||||
let resp = c.post(&upload_url)
|
||||
.header("Accept", "application/json, text/plain;q=0.9, */*;q=0.8")
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let raw = resp.text().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
// Network/CDN level failure — surface raw.
|
||||
let snippet = raw.chars().take(240).collect::<String>();
|
||||
return Err(AppError::HosterError(
|
||||
"Byse".into(),
|
||||
format!("Upload fehlgeschlagen (HTTP {}): {}", status.as_u16(), snippet),
|
||||
));
|
||||
}
|
||||
|
||||
let payload: ByseResp = serde_json::from_str(&raw)
|
||||
.map_err(|_| AppError::BadResponse(format!("Byse: Antwort war kein JSON: {}", &raw[..raw.len().min(240)])))?;
|
||||
|
||||
// Normal success: files[0].filecode present.
|
||||
if let Some(f) = payload.files.as_ref().and_then(|v| v.first()) {
|
||||
let code = f.filecode.clone().or(f.file_code.clone()).unwrap_or_default();
|
||||
if !code.is_empty() {
|
||||
return Ok(UploadResult {
|
||||
download_url: Some(format!("{DOWNLOAD_BASE}/d/{code}")),
|
||||
embed_url: Some(format!("{DOWNLOAD_BASE}/e/{code}")),
|
||||
file_code: Some(code),
|
||||
});
|
||||
}
|
||||
// Per-file rejection (e.g. "Not video file format") → but we've seen
|
||||
// the file land anyway. Poll before giving up.
|
||||
if let Some(s) = &f.status {
|
||||
if !is_ok_ish(s) {
|
||||
tracing::warn!("Byse per-file status `{s}` — polling file list to confirm");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poll /api/file/list for the uploaded filename.
|
||||
if let Some(found) = poll_for_upload(&c, key, &file_name, &baseline_set, &ctx).await {
|
||||
return Ok(UploadResult {
|
||||
download_url: Some(format!("{DOWNLOAD_BASE}/d/{}", found)),
|
||||
embed_url: Some(format!("{DOWNLOAD_BASE}/e/{}", found)),
|
||||
file_code: Some(found),
|
||||
});
|
||||
}
|
||||
|
||||
// Nothing landed on the account. If server reported a per-file status,
|
||||
// surface that as the error; else a generic one.
|
||||
let err_msg = payload
|
||||
.files
|
||||
.as_ref()
|
||||
.and_then(|v| v.first())
|
||||
.and_then(|f| f.status.clone())
|
||||
.filter(|s| !is_ok_ish(s))
|
||||
.map(|s| format!("Byse lehnte Datei ab: {s}"))
|
||||
.unwrap_or_else(|| format!("Byse: Keine file_code-Antwort (Payload: {})", &raw[..raw.len().min(400)]));
|
||||
Err(if err_msg.contains("lehnte Datei ab") {
|
||||
AppError::FileRejected(err_msg)
|
||||
} else {
|
||||
AppError::HosterError("Byse".into(), err_msg)
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_upload_server(c: &Client, key: &str) -> AppResult<String> {
|
||||
let url = format!("{API_BASE}/upload/server?key={}", urlencoding::encode(key));
|
||||
let resp = c.get(&url).header("Accept", "application/json").send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(AppError::HosterError("Byse".into(),
|
||||
format!("/upload/server HTTP {}: {}", status, &text[..text.len().min(200)])));
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(&text).map_err(|_|
|
||||
AppError::BadResponse(format!("Byse /upload/server kein JSON: {}", &text[..text.len().min(200)])))?;
|
||||
// Common shapes: { result: "https://..." } or { upload_url: "..." }
|
||||
for k in ["result", "upload_url", "url", "server"] {
|
||||
if let Some(s) = v.get(k).and_then(|x| x.as_str()) {
|
||||
if s.starts_with("http") { return Ok(s.to_string()); }
|
||||
}
|
||||
}
|
||||
Err(AppError::BadResponse("Byse: Kein Upload-Server erhalten".into()))
|
||||
}
|
||||
|
||||
fn append_query(url: &str, key: &str, val: &str) -> String {
|
||||
if url.contains('?') {
|
||||
format!("{url}&{key}={}", urlencoding::encode(val))
|
||||
} else {
|
||||
format!("{url}?{key}={}", urlencoding::encode(val))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ok_ish(s: &str) -> bool {
|
||||
let l = s.to_lowercase();
|
||||
matches!(l.as_str(), "ok" | "success" | "done")
|
||||
}
|
||||
|
||||
// --- File-list polling ---
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct ByseFile {
|
||||
file_code: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn fetch_file_list(c: &Client, key: &str) -> AppResult<Vec<ByseFile>> {
|
||||
let url = format!("{API_BASE}/api/file/list?key={}&per_page=100&sort=date&order=desc",
|
||||
urlencoding::encode(key));
|
||||
let resp = c.get(&url)
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() { return Ok(vec![]); }
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
let v: serde_json::Value = match serde_json::from_str(&text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
let arr = v.get("files").and_then(|x| x.as_array()).cloned()
|
||||
.or_else(|| v.pointer("/result/files").and_then(|x| x.as_array()).cloned())
|
||||
.or_else(|| v.get("result").and_then(|x| x.as_array()).cloned())
|
||||
.unwrap_or_default();
|
||||
for f in arr {
|
||||
let file_code = f.get("file_code").and_then(|x| x.as_str())
|
||||
.or_else(|| f.get("filecode").and_then(|x| x.as_str()))
|
||||
.unwrap_or("").to_string();
|
||||
if file_code.is_empty() { continue; }
|
||||
let name = f.get("title").and_then(|x| x.as_str())
|
||||
.or_else(|| f.get("name").and_then(|x| x.as_str()))
|
||||
.or_else(|| f.get("file_name").and_then(|x| x.as_str()))
|
||||
.unwrap_or("").to_string();
|
||||
list.push(ByseFile { file_code, name });
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
fn normalize_title(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()); }
|
||||
}
|
||||
// Strip trailing extension, e.g. ".mkv"
|
||||
if let Some(idx) = out.rfind(|_| false) { let _ = idx; }
|
||||
out
|
||||
}
|
||||
|
||||
async fn poll_for_upload(
|
||||
c: &Client,
|
||||
key: &str,
|
||||
file_name: &str,
|
||||
baseline: &std::collections::HashSet<String>,
|
||||
ctx: &UploadCtx,
|
||||
) -> Option<String> {
|
||||
let expected = {
|
||||
// strip file extension before normalizing
|
||||
let stripped = std::path::Path::new(file_name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(file_name);
|
||||
normalize_title(stripped)
|
||||
};
|
||||
for _ in 0..15 {
|
||||
if ctx.is_aborted() { return None; }
|
||||
if let Ok(list) = fetch_file_list(c, key).await {
|
||||
let new_files: Vec<_> = list.into_iter()
|
||||
.filter(|f| !baseline.contains(&f.file_code))
|
||||
.collect();
|
||||
if let Some(exact) = new_files.iter()
|
||||
.find(|f| normalize_title(&f.name) == expected) {
|
||||
return Some(exact.file_code.clone());
|
||||
}
|
||||
if new_files.len() == 1 {
|
||||
return Some(new_files[0].file_code.clone());
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Response shape ---
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ByseResp {
|
||||
#[allow(dead_code)]
|
||||
msg: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
status: Option<u32>,
|
||||
files: Option<Vec<ByseFileEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ByseFileEntry {
|
||||
filecode: Option<String>,
|
||||
#[serde(rename = "file_code")]
|
||||
file_code: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
filename: Option<String>,
|
||||
status: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Clouddrop.cc uploader. Port of `lib/clouddrop-upload.js`.
|
||||
//!
|
||||
//! Flow:
|
||||
//! - files <= 16 MB → single POST /api/cloud/upload?mode=rename (multipart)
|
||||
//! - files > 16 MB → POST /api/cloud/upload/init → PUT chunks @ upload.clouddrop.cc → POST /complete
|
||||
//!
|
||||
//! No share-link is created (server has link generation disabled by design).
|
||||
//! The download_url is constructed from the returned file_code.
|
||||
|
||||
use super::{UploadCtx, UploadTask};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::events::UploadResult;
|
||||
|
||||
use bytes::Bytes;
|
||||
use reqwest::{multipart, Body, Client};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
const BASE_URL: &str = "https://clouddrop.cc";
|
||||
const CHUNK_UPLOAD_BASE: &str = "https://upload.clouddrop.cc/api/cloud";
|
||||
const SIMPLE_UPLOAD_LIMIT: u64 = 16 * 1024 * 1024;
|
||||
const CHUNK_SIZE: u64 = 16 * 1024 * 1024;
|
||||
|
||||
fn client() -> AppResult<Client> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(30 * 60))
|
||||
.connect_timeout(Duration::from_secs(60))
|
||||
.pool_max_idle_per_host(50)
|
||||
.user_agent("multi-hoster-uploader/2.0")
|
||||
.build()
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult<UploadResult> {
|
||||
if task.api_key.trim().is_empty() {
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
let path = task.file_path.as_path();
|
||||
let meta = tokio::fs::metadata(path).await
|
||||
.map_err(|_| AppError::Other(format!("Clouddrop: Datei nicht lesbar: {}", path.display())))?;
|
||||
let file_size = meta.len();
|
||||
if file_size == 0 {
|
||||
return Err(AppError::Other("Clouddrop: Datei ist leer".into()));
|
||||
}
|
||||
|
||||
let c = client()?;
|
||||
let file_id = if file_size <= SIMPLE_UPLOAD_LIMIT {
|
||||
upload_simple(&c, path, file_size, &task, &ctx).await?
|
||||
} else {
|
||||
upload_chunked(&c, path, file_size, &task, &ctx).await?
|
||||
};
|
||||
|
||||
Ok(UploadResult {
|
||||
download_url: Some(format!("{BASE_URL}/share/{file_id}")),
|
||||
embed_url: None,
|
||||
file_code: Some(file_id),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Simple single-POST upload ---
|
||||
|
||||
async fn upload_simple(
|
||||
c: &Client,
|
||||
path: &Path,
|
||||
file_size: u64,
|
||||
task: &UploadTask,
|
||||
ctx: &UploadCtx,
|
||||
) -> AppResult<String> {
|
||||
let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
|
||||
let file = File::open(path).await?;
|
||||
let stream = progress_stream(file, file_size, ctx.clone());
|
||||
let body = Body::wrap_stream(stream);
|
||||
|
||||
let part = multipart::Part::stream_with_length(body, file_size)
|
||||
.file_name(file_name)
|
||||
.mime_str("application/octet-stream")
|
||||
.map_err(|e| AppError::Other(format!("MIME build failed: {e}")))?;
|
||||
|
||||
let form = multipart::Form::new().part("file", part);
|
||||
|
||||
let url = format!("{BASE_URL}/api/cloud/upload?mode=rename");
|
||||
let resp = c.post(&url)
|
||||
.bearer_auth(&task.api_key)
|
||||
.header("Accept", "application/json")
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let payload: SimpleResp = parse_json(resp).await?;
|
||||
payload.file_id
|
||||
.ok_or_else(|| AppError::BadResponse("Clouddrop: Keine fileId in Upload-Antwort".into()))
|
||||
}
|
||||
|
||||
// --- Chunked upload ---
|
||||
|
||||
async fn upload_chunked(
|
||||
c: &Client,
|
||||
path: &Path,
|
||||
file_size: u64,
|
||||
task: &UploadTask,
|
||||
ctx: &UploadCtx,
|
||||
) -> AppResult<String> {
|
||||
let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
|
||||
// 1) init session
|
||||
let init_url = format!("{BASE_URL}/api/cloud/upload/init");
|
||||
let init_payload = serde_json::json!({
|
||||
"filename": file_name,
|
||||
"size": file_size,
|
||||
"parentId": serde_json::Value::Null
|
||||
});
|
||||
let init_resp = c.post(&init_url)
|
||||
.bearer_auth(&task.api_key)
|
||||
.header("Accept", "application/json")
|
||||
.json(&init_payload)
|
||||
.send()
|
||||
.await?;
|
||||
let init: InitResp = parse_json(init_resp).await?;
|
||||
let session_id = init.session_id
|
||||
.ok_or_else(|| AppError::BadResponse("Clouddrop: Keine sessionId von /upload/init".into()))?;
|
||||
let chunk_size = init.chunk_size.unwrap_or(CHUNK_SIZE);
|
||||
let total_chunks = init.total_chunks.unwrap_or_else(|| file_size.div_ceil(chunk_size));
|
||||
|
||||
// 2) read + PUT chunks sequentially
|
||||
let mut file = File::open(path).await?;
|
||||
let mut buf = vec![0u8; chunk_size as usize];
|
||||
let mut bytes_sent: u64 = 0;
|
||||
|
||||
for i in 0..total_chunks {
|
||||
if ctx.is_aborted() {
|
||||
return Err(AppError::Aborted);
|
||||
}
|
||||
let offset = i * chunk_size;
|
||||
let remaining = file_size - offset;
|
||||
let this_size = chunk_size.min(remaining) as usize;
|
||||
file.seek(SeekFrom::Start(offset)).await?;
|
||||
file.read_exact(&mut buf[..this_size]).await?;
|
||||
ctx.throttle(this_size as u64).await;
|
||||
|
||||
let url = format!("{CHUNK_UPLOAD_BASE}/upload/{session_id}/chunk/{i}");
|
||||
let chunk_body = Bytes::copy_from_slice(&buf[..this_size]);
|
||||
let resp = c.put(&url)
|
||||
.bearer_auth(&task.api_key)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("Accept", "application/json")
|
||||
.body(chunk_body)
|
||||
.send()
|
||||
.await?;
|
||||
let _: serde_json::Value = parse_json(resp).await?;
|
||||
|
||||
bytes_sent += this_size as u64;
|
||||
(ctx.on_progress)(bytes_sent, file_size);
|
||||
}
|
||||
|
||||
// 3) complete (swallow all errors — bytes are already on the server)
|
||||
let complete_url = format!("{BASE_URL}/api/cloud/upload/{session_id}/complete");
|
||||
if let Ok(resp) = c.post(&complete_url)
|
||||
.bearer_auth(&task.api_key)
|
||||
.header("Accept", "application/json")
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
if let Ok(cmp) = parse_json::<CompleteResp>(resp).await {
|
||||
if let Some(id) = cmp.file_id.or(cmp.id) { return Ok(id); }
|
||||
}
|
||||
}
|
||||
// Fall back to sessionId — prevents the upload-manager from retrying a
|
||||
// multi-GB upload just because /complete hiccuped after all bytes landed.
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
async fn parse_json<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> AppResult<T> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
// Try to lift the server's `error` / `message` field out for a better
|
||||
// error message. Otherwise fall back to the raw snippet.
|
||||
let msg = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("error").and_then(|e| e.as_str().map(|s| s.to_string()))
|
||||
.or_else(|| v.get("message").and_then(|e| e.as_str().map(|s| s.to_string())))
|
||||
})
|
||||
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
return Err(AppError::HosterError("Clouddrop".into(), msg));
|
||||
}
|
||||
if text.is_empty() {
|
||||
// serde_json can't parse empty — return a default value if T allows it.
|
||||
let v = serde_json::from_str::<T>("{}")?;
|
||||
return Ok(v);
|
||||
}
|
||||
Ok(serde_json::from_str::<T>(&text)?)
|
||||
}
|
||||
|
||||
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_inner = ctx1.clone();
|
||||
let ctx_progress = ctx2.clone();
|
||||
async move {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
if ctx_inner.is_aborted() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Other, "Aborted"));
|
||||
}
|
||||
ctx_inner.throttle(bytes.len() as u64).await;
|
||||
acc += bytes.len() as u64;
|
||||
(ctx_progress.on_progress)(acc, total);
|
||||
Ok(bytes)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Response shapes ---
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct SimpleResp {
|
||||
#[serde(rename = "fileId")]
|
||||
file_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct InitResp {
|
||||
#[serde(rename = "sessionId")]
|
||||
session_id: Option<String>,
|
||||
#[serde(rename = "chunkSize")]
|
||||
chunk_size: Option<u64>,
|
||||
#[serde(rename = "totalChunks")]
|
||||
total_chunks: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct CompleteResp {
|
||||
#[serde(rename = "fileId")]
|
||||
file_id: Option<String>,
|
||||
id: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Doodstream.com uploader — port of `lib/doodstream-upload.js` (TODO).
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use super::{UploadCtx, UploadTask};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::events::UploadResult;
|
||||
|
||||
pub async fn upload(_task: UploadTask, _ctx: UploadCtx) -> AppResult<UploadResult> {
|
||||
Err(AppError::Other(
|
||||
"Doodstream-Uploader in 2.0 noch nicht portiert. Nutze bis dahin v1."
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Per-hoster uploaders + shared plumbing.
|
||||
//!
|
||||
//! Each hoster module exposes a single async `upload` function with the same
|
||||
//! signature (see `UploadFn`). The dispatcher (`upload_file`) routes to the
|
||||
//! right one based on `task.hoster`.
|
||||
|
||||
pub mod clouddrop;
|
||||
pub mod byse;
|
||||
pub mod vidmoly;
|
||||
pub mod doodstream;
|
||||
pub mod voe;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::events::UploadResult;
|
||||
use crate::throttle::Throttle;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// What the upload manager hands a hoster module.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UploadTask {
|
||||
pub hoster: String,
|
||||
pub file_path: std::path::PathBuf,
|
||||
pub account_id: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// Shared context: abort signal + optional throttles + progress callback.
|
||||
#[derive(Clone)]
|
||||
pub struct UploadCtx {
|
||||
pub abort: Arc<Notify>,
|
||||
pub aborted_flag: Arc<std::sync::atomic::AtomicBool>,
|
||||
pub throttle_hoster: Option<Throttle>,
|
||||
pub throttle_global: Option<Throttle>,
|
||||
/// Fires whenever another chunk of bytes has been accepted for transmission.
|
||||
/// Signature: (bytes_uploaded, bytes_total)
|
||||
pub on_progress: Arc<dyn Fn(u64, u64) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl UploadCtx {
|
||||
pub fn is_aborted(&self) -> bool {
|
||||
self.aborted_flag.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub async fn throttle(&self, n: u64) {
|
||||
if let Some(t) = &self.throttle_hoster { t.consume(n).await; }
|
||||
if let Some(t) = &self.throttle_global { t.consume(n).await; }
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch: route to the hoster-specific uploader.
|
||||
pub async fn upload_file(task: UploadTask, ctx: UploadCtx) -> AppResult<UploadResult> {
|
||||
match task.hoster.as_str() {
|
||||
"clouddrop.cc" => clouddrop::upload(task, ctx).await,
|
||||
"byse.sx" => byse::upload(task, ctx).await,
|
||||
"vidmoly.me" => vidmoly::upload(task, ctx).await,
|
||||
"doodstream.com" => doodstream::upload(task, ctx).await,
|
||||
"voe.sx" => voe::upload(task, ctx).await,
|
||||
other => Err(AppError::Other(format!("Unbekannter Hoster: {other}"))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Vidmoly.me uploader. Port of `lib/vidmoly-upload.js`.
|
||||
//!
|
||||
//! Modern (post-SPA) flow:
|
||||
//! 1. GET https://vidmoly.me/ (warm up)
|
||||
//! 2. POST /api/auth/login JSON {login, password} → sets `vidmoly_session`
|
||||
//! 3. GET /api/upload/config → { sess_id, upload_url }
|
||||
//! 4. POST `{upload_url}?X-Progress-ID=<random>`
|
||||
//! multipart sess_id=, to_json=1, fld_id=0, file=<binary>
|
||||
//! 5. JSON response: { status: "OK", file_code: "...", msg: "Upload Completed" }
|
||||
//!
|
||||
//! IMPORTANT: vidmoly.me cookies must NOT be sent to the transit server
|
||||
//! (different origin). reqwest's cookie store handles that automatically
|
||||
//! because cookies are domain-scoped.
|
||||
|
||||
use super::{UploadCtx, UploadTask};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::events::UploadResult;
|
||||
|
||||
use bytes::Bytes;
|
||||
use reqwest::{multipart, Body, Client};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::fs::File;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
const BASE_URL: &str = "https://vidmoly.me";
|
||||
|
||||
fn logged_in_client() -> AppResult<Client> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(30 * 60))
|
||||
.connect_timeout(Duration::from_secs(60))
|
||||
.cookie_store(true)
|
||||
.pool_max_idle_per_host(10)
|
||||
.gzip(true)
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||
.build()
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
fn transit_client() -> AppResult<Client> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(30 * 60))
|
||||
.connect_timeout(Duration::from_secs(60))
|
||||
.pool_max_idle_per_host(5)
|
||||
.gzip(true)
|
||||
// No cookie store → cross-origin transit upload stays clean
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||
.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 = Arc::new(logged_in_client()?);
|
||||
|
||||
// --- Login ---
|
||||
// Warm-up GET establishes baseline cookies (cf_clearance, i18n_lang).
|
||||
let _ = c.get(BASE_URL).send().await;
|
||||
|
||||
let login_resp = c.post(format!("{BASE_URL}/api/auth/login"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.header("Origin", BASE_URL)
|
||||
.header("Referer", format!("{BASE_URL}/login"))
|
||||
.json(&serde_json::json!({ "login": task.username, "password": task.password }))
|
||||
.send()
|
||||
.await?;
|
||||
let status = login_resp.status();
|
||||
let body = login_resp.text().await.unwrap_or_default();
|
||||
if status == 401 || status == 403 || regex::Regex::new(r"(?i)incorrect|invalid|wrong").unwrap().is_match(&body) {
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(AppError::HosterError("Vidmoly".into(), format!("Login HTTP {}", status.as_u16())));
|
||||
}
|
||||
|
||||
// --- Upload config (confirms session is valid) ---
|
||||
let cfg_resp = c.get(format!("{BASE_URL}/api/upload/config"))
|
||||
.header("Accept", "application/json")
|
||||
.send().await?;
|
||||
if !cfg_resp.status().is_success() {
|
||||
return Err(AppError::HosterError("Vidmoly".into(),
|
||||
format!("/api/upload/config HTTP {}", cfg_resp.status().as_u16())));
|
||||
}
|
||||
let cfg: UploadConfig = serde_json::from_str(&cfg_resp.text().await.unwrap_or_default())
|
||||
.map_err(|e| AppError::BadResponse(format!("Vidmoly: /api/upload/config kein JSON: {e}")))?;
|
||||
let sess_id = cfg.sess_id
|
||||
.ok_or_else(|| AppError::BadResponse("Vidmoly: sess_id fehlt".into()))?;
|
||||
let upload_url = cfg.upload_url
|
||||
.ok_or_else(|| AppError::BadResponse("Vidmoly: upload_url fehlt".into()))?;
|
||||
|
||||
// --- Transit upload ---
|
||||
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 progress_id = format!("{}{:06}",
|
||||
chrono::Utc::now().timestamp_millis(),
|
||||
rand::Rng::gen_range(&mut rand::thread_rng(), 0u32..1_000_000));
|
||||
let target_url = if upload_url.contains('?') {
|
||||
format!("{upload_url}&X-Progress-ID={progress_id}")
|
||||
} else {
|
||||
format!("{upload_url}?X-Progress-ID={progress_id}")
|
||||
};
|
||||
|
||||
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("to_json", "1")
|
||||
.text("fld_id", "0")
|
||||
.part("file", part);
|
||||
|
||||
let tc = transit_client()?;
|
||||
let resp = tc.post(&target_url)
|
||||
.header("Accept", "*/*")
|
||||
.header("Origin", BASE_URL)
|
||||
.header("Referer", format!("{BASE_URL}/"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let raw = resp.text().await.unwrap_or_default();
|
||||
|
||||
// Try JSON success shape.
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
let 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 let Some(code) = code {
|
||||
if !code.is_empty() {
|
||||
return Ok(UploadResult {
|
||||
download_url: Some(format!("{BASE_URL}/w/{code}")),
|
||||
embed_url: Some(format!("{BASE_URL}/embed-{code}.html")),
|
||||
file_code: Some(code.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(s) = v.get("status").and_then(|x| x.as_str()) {
|
||||
if !s.eq_ignore_ascii_case("ok") {
|
||||
let msg = v.get("msg").and_then(|x| x.as_str()).unwrap_or(s).to_string();
|
||||
return Err(AppError::HosterError("Vidmoly".into(), msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::BadResponse(format!(
|
||||
"Vidmoly: unerwartete Upload-Antwort (HTTP {}): {}",
|
||||
status.as_u16(),
|
||||
&raw[..raw.len().min(400)]
|
||||
)))
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct UploadConfig {
|
||||
sess_id: Option<String>,
|
||||
upload_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct UploadResp {
|
||||
status: Option<String>,
|
||||
file_code: Option<String>,
|
||||
msg: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! VOE.sx uploader — port of `lib/voe-upload.js` (TODO).
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use super::{UploadCtx, UploadTask};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::events::UploadResult;
|
||||
|
||||
pub async fn upload(_task: UploadTask, _ctx: UploadCtx) -> AppResult<UploadResult> {
|
||||
Err(AppError::Other(
|
||||
"VOE-Uploader in 2.0 noch nicht portiert. Nutze bis dahin v1."
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user