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
303 lines
11 KiB
Rust
303 lines
11 KiB
Rust
//! 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>,
|
|
}
|