//! 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::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 { 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 = 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::(); 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 { 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> { 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, ctx: &UploadCtx, ) -> Option { 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> + 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, #[allow(dead_code)] status: Option, files: Option>, } #[derive(Deserialize, Default)] struct ByseFileEntry { filecode: Option, #[serde(rename = "file_code")] file_code: Option, #[allow(dead_code)] filename: Option, status: Option, }