diff --git a/scripts/release_gitea.mjs b/scripts/release_gitea.mjs new file mode 100644 index 0000000..c479f91 --- /dev/null +++ b/scripts/release_gitea.mjs @@ -0,0 +1,108 @@ +#!/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 [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); }); diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index d96f1a5..dfc45ba 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -152,3 +152,36 @@ impl Serialize for AppError { } pub type AppResult = Result; + +#[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()); + } +} + diff --git a/src-tauri/src/folder_monitor.rs b/src-tauri/src/folder_monitor.rs index 581bf78..4993592 100644 --- a/src-tauri/src/folder_monitor.rs +++ b/src-tauri/src/folder_monitor.rs @@ -171,6 +171,40 @@ 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::::new()); + assert_eq!(parse_extensions(", ,"), Vec::::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) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for entry in rd.flatten() { diff --git a/src-tauri/src/throttle.rs b/src-tauri/src/throttle.rs index b0fb0cc..6a4465a 100644 --- a/src-tauri/src/throttle.rs +++ b/src-tauri/src/throttle.rs @@ -36,7 +36,13 @@ 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) = { @@ -57,3 +63,22 @@ 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); + } +} diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 0a51287..ef00707 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -79,6 +79,31 @@ 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 { diff --git a/src-tauri/src/upload_log.rs b/src-tauri/src/upload_log.rs index e505783..e794c5b 100644 --- a/src-tauri/src/upload_log.rs +++ b/src-tauri/src/upload_log.rs @@ -137,17 +137,20 @@ impl UploadLogWriter { if let Some(state) = self.app.try_state::() { 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(); - let _ = store.save_global(gs).await; + if store.save_global(gs).await.is_ok() { + let _ = app.emit("log-path-auto-updated", + serde_json::json!({ "logFilePath": save_path.display().to_string() })); + } } }); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 69221a9..2cee097 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,7 +28,10 @@ }, "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.", @@ -41,7 +44,10 @@ "nsis": { "installMode": "perMachine", "installerIcon": "icons/icon.ico", - "languages": ["German", "English"] + "languages": [ + "German", + "English" + ] } } }