Compare commits
No commits in common. "master" and "v2.0.0" have entirely different histories.
@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { execSync } from 'child_process';
|
||||
import { readFileSync, writeFileSync, statSync, existsSync } from 'fs';
|
||||
import { resolve, basename } from 'path';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const PRODUCT_NAME = 'Multi-Hoster-Upload';
|
||||
const REPO = 'Administrator/Multi-Hoster-Upload-2';
|
||||
const BASE = process.env.GITEA_BASE_URL || 'https://git.24-music.de';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const version = args.find(a => /^\d+\.\d+\.\d+$/.test(a));
|
||||
const notes = args.filter(a => a !== version).join(' ') || `${PRODUCT_NAME} v${version}`;
|
||||
if (!version) {
|
||||
console.error('Usage: node scripts/release_gitea.mjs <version> [notes]');
|
||||
process.exit(1);
|
||||
}
|
||||
const tag = `v${version}`;
|
||||
|
||||
const sh = (cmd) => { console.log(` $ ${cmd}`); return execSync(cmd, { cwd: ROOT, encoding: 'utf-8' }).trim(); };
|
||||
|
||||
function resolveToken() {
|
||||
if (process.env.GITEA_TOKEN) return process.env.GITEA_TOKEN;
|
||||
const out = execSync('git credential fill',
|
||||
{ input: 'protocol=https\nhost=git.24-music.de\n\n', encoding: 'utf-8', cwd: ROOT });
|
||||
return (out.match(/password=(.+)/) || [])[1]?.trim();
|
||||
}
|
||||
|
||||
async function api(method, path, token, body) {
|
||||
const r = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await r.text();
|
||||
let json = null; try { json = JSON.parse(text); } catch {}
|
||||
if (!r.ok && r.status !== 409 && r.status !== 422)
|
||||
throw new Error(`Gitea ${r.status}: ${text.slice(0, 300)}`);
|
||||
return { status: r.status, data: json };
|
||||
}
|
||||
|
||||
async function uploadAsset(releaseId, filePath, token) {
|
||||
const name = basename(filePath);
|
||||
const size = statSync(filePath).size;
|
||||
console.log(` Uploading ${name} (${(size/1024/1024).toFixed(1)} MB)...`);
|
||||
const buf = readFileSync(filePath);
|
||||
const form = new FormData();
|
||||
form.append('attachment', new Blob([buf]), name);
|
||||
const r = await fetch(
|
||||
`${BASE}/api/v1/repos/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`,
|
||||
{ method: 'POST', headers: { Authorization: `token ${token}` }, body: form }
|
||||
);
|
||||
if (!r.ok) throw new Error(`upload ${name}: ${r.status}`);
|
||||
console.log(` ok`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const token = resolveToken();
|
||||
if (!token) { console.error('No token'); process.exit(1); }
|
||||
|
||||
try { sh('git remote get-url gitea'); }
|
||||
catch { sh(`git remote add gitea https://git.24-music.de/${REPO}.git`); }
|
||||
|
||||
const pkgPath = resolve(ROOT, 'package.json');
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||||
pkg.version = version;
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
|
||||
const cargoPath = resolve(ROOT, 'src-tauri/Cargo.toml');
|
||||
const cargo = readFileSync(cargoPath, 'utf-8').replace(/^version = "[^"]+"/m, `version = "${version}"`);
|
||||
writeFileSync(cargoPath, cargo);
|
||||
|
||||
const tauriPath = resolve(ROOT, 'src-tauri/tauri.conf.json');
|
||||
const tauri = JSON.parse(readFileSync(tauriPath, 'utf-8'));
|
||||
tauri.version = version;
|
||||
writeFileSync(tauriPath, JSON.stringify(tauri, null, 2) + '\n');
|
||||
|
||||
sh('git add package.json src-tauri/Cargo.toml src-tauri/tauri.conf.json');
|
||||
try { sh(`git commit -m "release: ${tag}"`); } catch {}
|
||||
try { sh(`git tag ${tag}`); } catch {}
|
||||
try { sh('git push gitea HEAD:master'); } catch (e) { console.log(' push HEAD warn'); }
|
||||
try { sh(`git push gitea ${tag}`); } catch {}
|
||||
|
||||
const exePath = resolve(ROOT, 'src-tauri/target/release/multi-hoster-upload.exe');
|
||||
const nsisPath = resolve(ROOT, `src-tauri/target/release/bundle/nsis/Multi-Hoster-Upload_${version}_x64-setup.exe`);
|
||||
const msiPath = resolve(ROOT, `src-tauri/target/release/bundle/msi/Multi-Hoster-Upload_${version}_x64_en-US.msi`);
|
||||
for (const p of [exePath, nsisPath, msiPath]) {
|
||||
if (!existsSync(p)) { console.error(`Missing: ${p}`); process.exit(1); }
|
||||
}
|
||||
|
||||
let releaseId;
|
||||
const created = await api('POST', `/api/v1/repos/${REPO}/releases`, token, {
|
||||
tag_name: tag, name: `${PRODUCT_NAME} ${tag}`, body: notes
|
||||
});
|
||||
if (created.status === 409 || created.status === 422) {
|
||||
const existing = await api('GET', `/api/v1/repos/${REPO}/releases/tags/${tag}`, token);
|
||||
releaseId = existing.data.id;
|
||||
console.log(`Release exists (id=${releaseId})`);
|
||||
} else {
|
||||
releaseId = created.data.id;
|
||||
console.log(`Created release (id=${releaseId})`);
|
||||
}
|
||||
|
||||
await uploadAsset(releaseId, exePath, token);
|
||||
await uploadAsset(releaseId, nsisPath, token);
|
||||
await uploadAsset(releaseId, msiPath, token);
|
||||
console.log(`\nhttps://git.24-music.de/${REPO}/releases/tag/${tag}`);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@ -354,135 +354,29 @@ pub async fn run_health_check(
|
||||
}
|
||||
|
||||
async fn check_account_live(hoster: &str, a: &Account) -> AppResult<String> {
|
||||
// Byse's account-info endpoint returns HTTP 200 with {"status":403} on bad
|
||||
// keys, so we parse the JSON properly instead of just checking the status.
|
||||
let timeout = std::time::Duration::from_secs(20);
|
||||
match hoster {
|
||||
"clouddrop.cc" => {
|
||||
if a.api_key.is_empty() { return Err(AppError::BadCredentials); }
|
||||
let c = reqwest::Client::builder().timeout(timeout).build()?;
|
||||
let r = c.get("https://clouddrop.cc/api/cloud/files/?limit=1")
|
||||
.bearer_auth(&a.api_key).send().await?;
|
||||
match r.status().as_u16() {
|
||||
200..=299 => Ok("API Key gültig".into()),
|
||||
401 | 403 => Err(AppError::BadCredentials),
|
||||
code => Err(AppError::HosterError("Clouddrop".into(), format!("HTTP {code}"))),
|
||||
}
|
||||
let c = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).build()?;
|
||||
let r = c.get("https://clouddrop.cc/api/cloud/files/?limit=1").bearer_auth(&a.api_key).send().await?;
|
||||
if r.status().is_success() { Ok("API Key gültig".into()) } else { Err(AppError::HosterError("Clouddrop".into(), format!("HTTP {}", r.status().as_u16()))) }
|
||||
}
|
||||
"byse.sx" => {
|
||||
if a.api_key.is_empty() { return Err(AppError::BadCredentials); }
|
||||
let c = reqwest::Client::builder().timeout(timeout).build()?;
|
||||
let c = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).build()?;
|
||||
let r = c.get(format!("https://api.byse.sx/api/account/info?key={}", urlencoding::encode(&a.api_key))).send().await?;
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
let v: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
|
||||
let status = v.get("status").and_then(|x| x.as_u64()).unwrap_or(200);
|
||||
if status == 200 { Ok("API Key gültig".into()) }
|
||||
else if status == 401 || status == 403 { Err(AppError::BadCredentials) }
|
||||
else { Err(AppError::HosterError("Byse".into(),
|
||||
v.get("msg").and_then(|x| x.as_str()).unwrap_or("Fehler").to_string())) }
|
||||
}
|
||||
"vidmoly.me" => {
|
||||
if a.username.is_empty() || a.password.is_empty() { return Err(AppError::BadCredentials); }
|
||||
let c = reqwest::Client::builder()
|
||||
.timeout(timeout).cookie_store(true).build()?;
|
||||
let _ = c.get("https://vidmoly.me").send().await;
|
||||
let res = c.post("https://vidmoly.me/api/auth/login")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.header("Origin", "https://vidmoly.me")
|
||||
.header("Referer", "https://vidmoly.me/login")
|
||||
.json(&serde_json::json!({ "login": a.username, "password": a.password }))
|
||||
.send().await?;
|
||||
let code = res.status().as_u16();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
if code == 401 || code == 403 || body.to_lowercase().contains("incorrect")
|
||||
|| body.to_lowercase().contains("invalid") {
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
if !(200..300).contains(&code) {
|
||||
return Err(AppError::HosterError("Vidmoly".into(), format!("HTTP {code}")));
|
||||
}
|
||||
// Verify session works against the upload-config endpoint.
|
||||
let probe = c.get("https://vidmoly.me/api/upload/config")
|
||||
.header("Accept", "application/json").send().await?;
|
||||
if probe.status().is_success() { Ok("Login gültig".into()) }
|
||||
else { Err(AppError::BadCredentials) }
|
||||
}
|
||||
"doodstream.com" => {
|
||||
if a.username.is_empty() || a.password.is_empty() { return Err(AppError::BadCredentials); }
|
||||
let c = reqwest::Client::builder()
|
||||
.timeout(timeout).cookie_store(true).build()?;
|
||||
let _ = c.get("https://doodstream.com").send().await;
|
||||
let body = serde_urlencoded::to_string([
|
||||
("op", "login_ajax"),
|
||||
("login", a.username.as_str()),
|
||||
("password", a.password.as_str()),
|
||||
("loginotp", ""),
|
||||
]).unwrap_or_default();
|
||||
let res = c.post("https://doodstream.com/")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Referer", "https://doodstream.com/")
|
||||
.body(body).send().await?;
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
if text.contains("Dashboard") { return Ok("Login gültig".into()); }
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if let Some(s) = v.get("status").and_then(|x| x.as_str()) {
|
||||
if s == "success" { return Ok("Login gültig".into()); }
|
||||
let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("Login fehlgeschlagen");
|
||||
if msg.to_lowercase().contains("otp") { return Ok("Login gültig (OTP erforderlich)".into()); }
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
}
|
||||
// If we scrape a logged-in page successfully that's also good.
|
||||
let probe = c.get("https://doodstream.com/?op=my_files").send().await?;
|
||||
if probe.status().is_success() {
|
||||
let probe_text = probe.text().await.unwrap_or_default();
|
||||
if probe_text.contains("logout") || probe_text.contains("Logout")
|
||||
|| probe_text.contains("Dashboard") { return Ok("Login gültig".into()); }
|
||||
}
|
||||
Err(AppError::BadCredentials)
|
||||
}
|
||||
"voe.sx" => {
|
||||
if a.username.is_empty() || a.password.is_empty() { return Err(AppError::BadCredentials); }
|
||||
let c = reqwest::Client::builder()
|
||||
.timeout(timeout).cookie_store(true).build()?;
|
||||
let login_html = c.get("https://voe.sx/login").send().await?.text().await.unwrap_or_default();
|
||||
let csrf = regex::Regex::new(r#"<meta\s+name=["']csrf-token["']\s+content=["']([^"']+)["']"#).unwrap()
|
||||
.captures(&login_html).and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
|
||||
.or_else(|| regex::Regex::new(r#"<input[^>]*name=["']_token["'][^>]*value=["']([^"']+)["']"#).unwrap()
|
||||
.captures(&login_html).and_then(|c| c.get(1).map(|m| m.as_str().to_string())))
|
||||
.ok_or_else(|| AppError::HosterError("VOE".into(), "CSRF-Token nicht gefunden".into()))?;
|
||||
let body = serde_urlencoded::to_string([
|
||||
("_token", csrf.as_str()),
|
||||
("email", a.username.as_str()),
|
||||
("password", a.password.as_str()),
|
||||
]).unwrap_or_default();
|
||||
let res = c.post("https://voe.sx/login")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Referer", "https://voe.sx/login")
|
||||
.body(body).send().await?;
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
if text.contains("credentials do not match") || text.contains("Incorrect") || text.contains("invalid") {
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
// Confirm session by pulling the upload page and looking for a CSRF
|
||||
// (only present when logged in).
|
||||
let upload_html = c.get("https://voe.sx/file-upload").send().await?.text().await.unwrap_or_default();
|
||||
if regex::Regex::new(r#"name=["']csrf-token["']"#).unwrap().is_match(&upload_html) {
|
||||
Ok("Login gültig".into())
|
||||
} else {
|
||||
Err(AppError::BadCredentials)
|
||||
}
|
||||
if r.status().is_success() { Ok("API Key gültig".into()) } else { Err(AppError::HosterError("Byse".into(), format!("HTTP {}", r.status().as_u16()))) }
|
||||
}
|
||||
_ => {
|
||||
if a.auth_type == "login" && (a.username.is_empty() || a.password.is_empty()) {
|
||||
return Err(AppError::BadCredentials);
|
||||
if a.auth_type == "login" {
|
||||
if a.username.is_empty() || a.password.is_empty() { return Err(AppError::BadCredentials); }
|
||||
Ok("Login hinterlegt".into())
|
||||
} else if a.auth_type == "api" {
|
||||
if a.api_key.is_empty() { return Err(AppError::BadCredentials); }
|
||||
Ok("API Key hinterlegt".into())
|
||||
} else {
|
||||
Ok("Nicht geprüft".into())
|
||||
}
|
||||
if a.auth_type == "api" && a.api_key.is_empty() {
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
Ok("Nicht implementiert".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,36 +152,3 @@ impl Serialize for AppError {
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classify_account_specific() {
|
||||
assert!(AppError::BadCredentials.is_account_specific());
|
||||
assert!(AppError::HosterError("voe".into(), "too many requests".into()).is_account_specific());
|
||||
assert!(AppError::HosterError("byse".into(), "quota exceeded".into()).is_account_specific());
|
||||
assert!(AppError::HosterError("dood".into(), "CSRF-Token nicht gefunden".into()).is_account_specific());
|
||||
assert!(AppError::Other("HTTP 429".into()).is_account_specific());
|
||||
assert!(!AppError::Other("ENOTFOUND foo".into()).is_account_specific());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_transient_network() {
|
||||
assert!(AppError::Other("getaddrinfo ENOTFOUND s1055.filemoon".into()).is_transient_network());
|
||||
assert!(AppError::Other("ECONNRESET".into()).is_transient_network());
|
||||
assert!(AppError::Other("socket hang up".into()).is_transient_network());
|
||||
assert!(!AppError::Other("quota exceeded".into()).is_transient_network());
|
||||
assert!(!AppError::BadCredentials.is_transient_network());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_file_rejected() {
|
||||
assert!(AppError::FileRejected("Not video file format".into()).is_file_rejected());
|
||||
assert!(AppError::HosterError("byse".into(), "Byse lehnte Datei ab: Not video file format".into()).is_file_rejected());
|
||||
assert!(AppError::Other("Duplicate detected".into()).is_file_rejected());
|
||||
assert!(!AppError::Other("quota".into()).is_file_rejected());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -171,40 +171,6 @@ fn path_matches(p: &Path, extensions: &[String], include: bool) -> bool {
|
||||
if include { listed } else { !listed }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn parse_extensions_strips_dots_and_whitespace() {
|
||||
assert_eq!(parse_extensions(".mp4, mkv, .avi"), vec!["mp4", "mkv", "avi"]);
|
||||
assert_eq!(parse_extensions(""), Vec::<String>::new());
|
||||
assert_eq!(parse_extensions(", ,"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_filter_accepts_listed() {
|
||||
let p = PathBuf::from("video.mp4");
|
||||
assert!(path_matches(&p, &vec!["mp4".into(), "mkv".into()], true));
|
||||
assert!(!path_matches(&p, &vec!["avi".into()], true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_filter_rejects_listed() {
|
||||
let p = PathBuf::from("video.mp4");
|
||||
assert!(!path_matches(&p, &vec!["mp4".into()], false));
|
||||
assert!(path_matches(&p, &vec!["avi".into()], false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_extensions_accepts_everything() {
|
||||
let p = PathBuf::from("foo.anything");
|
||||
assert!(path_matches(&p, &[], true));
|
||||
assert!(path_matches(&p, &[], false));
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_collect(dir: &Path, recursive: bool, out: &mut HashSet<PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for entry in rd.flatten() {
|
||||
|
||||
@ -36,13 +36,7 @@ impl Throttle {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn max_bps(&self) -> u64 { self.inner.lock().max_bps }
|
||||
|
||||
/// Block until `bytes` worth of tokens are available.
|
||||
#[cfg(test)]
|
||||
pub fn available_tokens(&self) -> f64 { self.inner.lock().tokens }
|
||||
|
||||
pub async fn consume(&self, mut bytes: u64) {
|
||||
loop {
|
||||
let (take, remaining) = {
|
||||
@ -63,22 +57,3 @@ impl Throttle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unlimited_is_instant() {
|
||||
let t = Throttle::new(0);
|
||||
assert_eq!(t.max_bps(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rate_updates_limit() {
|
||||
let t = Throttle::new(1000);
|
||||
t.set_rate(500);
|
||||
assert_eq!(t.max_bps(), 500);
|
||||
assert!(t.available_tokens() <= 500.0);
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,31 +79,6 @@ pub async fn download_and_launch(app: tauri::AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn semver_compare_triggers_update() {
|
||||
let newer = Version::parse("2.1.0").unwrap();
|
||||
let current = Version::parse("2.0.0").unwrap();
|
||||
assert!(newer > current);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semver_equal_does_not_trigger() {
|
||||
let v1 = Version::parse("2.0.0").unwrap();
|
||||
let v2 = Version::parse("2.0.0").unwrap();
|
||||
assert!(!(v1 > v2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semver_strips_v_prefix() {
|
||||
assert!(Version::parse("2.0.0".trim_start_matches('v')).is_ok());
|
||||
assert!(Version::parse("v2.0.0".trim_start_matches('v')).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check() -> UpdateCheck {
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
let mut out = UpdateCheck {
|
||||
|
||||
@ -137,20 +137,17 @@ impl UploadLogWriter {
|
||||
if let Some(state) = self.app.try_state::<crate::commands::AppState>() {
|
||||
let store = state.config.clone();
|
||||
let to_save = path.to_path_buf();
|
||||
let app = self.app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Ok(cfg) = store.load() {
|
||||
let mut gs = cfg.global_settings.clone();
|
||||
// Strip daily suffix when daily-log is active — same as v1.
|
||||
let save_path = if gs.session_log {
|
||||
strip_daily_suffix(&to_save)
|
||||
} else {
|
||||
to_save.clone()
|
||||
};
|
||||
gs.log_file_path = save_path.display().to_string();
|
||||
if store.save_global(gs).await.is_ok() {
|
||||
let _ = app.emit("log-path-auto-updated",
|
||||
serde_json::json!({ "logFilePath": save_path.display().to_string() }));
|
||||
}
|
||||
let _ = store.save_global(gs).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -28,10 +28,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"nsis",
|
||||
"msi"
|
||||
],
|
||||
"targets": ["nsis", "msi"],
|
||||
"publisher": "xrangerde",
|
||||
"shortDescription": "Multi-Hoster file uploader",
|
||||
"longDescription": "Upload files to multiple video hosters with fallback accounts, retry logic and progress tracking.",
|
||||
@ -44,10 +41,7 @@
|
||||
"nsis": {
|
||||
"installMode": "perMachine",
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"languages": [
|
||||
"German",
|
||||
"English"
|
||||
]
|
||||
"languages": ["German", "English"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user