Multi-Hoster-Upload-2/src-tauri/src/hosters/byse.rs
Claude 8627a8e694 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
2026-04-20 17:08:00 +02:00

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>,
}