chore: initial commit - Electron multi-hoster uploader

This commit is contained in:
Administrator 2026-03-10 02:32:06 +01:00
commit 9729ec6f3e
17 changed files with 9346 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
release/
__pycache__/
*.pyc

44
README.md Normal file
View File

@ -0,0 +1,44 @@
# Multi Hoster Uploader
Desktop-Tool (Python + Tkinter), um Dateien auf mehrere Hoster mit deinem eigenen Account hochzuladen und direkt die Ergebnis-Links zu bekommen.
Aktuell integriert:
- doodstream.com (offizielle API)
- voe.sx (offizielle API)
- vidmoly.me (kompatibler API-Check, falls vorhanden)
- byse.sx (kompatibler API-Check, falls vorhanden)
## Wichtig
- Nur mit eigenen Accounts/API Keys nutzen.
- Beachte die jeweiligen ToS, Urheberrecht und lokale Gesetze.
- Bei Hostern ohne oeffentliche API (oder geaenderte Endpunkte) kann Upload fehlschlagen.
## Setup
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python app.py
```
## Nutzung
1. Pro Hoster den API Key eintragen (in vielen Panels unter Settings/API).
2. Hoster aktivieren/deaktivieren.
3. Dateien waehlen.
4. `Upload starten`.
5. Links aus `Output Links` kopieren.
Die Konfiguration wird lokal in `config.json` gespeichert.
## EXE bauen (optional)
```bash
pip install pyinstaller
pyinstaller --noconfirm --onefile --windowed --name multi-hoster-uploader app.py
```
Die EXE liegt danach unter `dist\multi-hoster-uploader.exe`.

244
app.py Normal file
View File

@ -0,0 +1,244 @@
from __future__ import annotations
import json
import queue
import threading
from dataclasses import dataclass
from pathlib import Path
from tkinter import END, LEFT, RIGHT, W, filedialog, messagebox
import tkinter as tk
from tkinter import ttk
from hosters import UploadError, UploadResult, build_uploaders
APP_DIR = Path(__file__).resolve().parent
CONFIG_PATH = APP_DIR / "config.json"
@dataclass
class HostRow:
host: str
enabled: tk.BooleanVar
credential: tk.StringVar
class UploaderApp:
def __init__(self, root: tk.Tk) -> None:
self.root = root
self.root.title("Multi Hoster Uploader")
self.root.geometry("980x680")
self.uploaders = build_uploaders()
self.files: list[Path] = []
self.log_queue: queue.Queue[str] = queue.Queue()
self.link_lines: list[str] = []
self.host_rows: dict[str, HostRow] = {}
self._build_ui()
self._load_config()
self._poll_logs()
def _build_ui(self) -> None:
wrapper = ttk.Frame(self.root, padding=12)
wrapper.pack(fill=tk.BOTH, expand=True)
title = ttk.Label(wrapper, text="Uploader fuer doodstream / voe / vidmoly / byse", font=("Segoe UI", 13, "bold"))
title.pack(anchor=W)
subtitle = ttk.Label(
wrapper,
text="Nutze deine eigenen Accounts/API Keys. Das Tool nutzt nur offizielle oder kompatible Upload-Endpunkte.",
)
subtitle.pack(anchor=W, pady=(2, 10))
hosts_frame = ttk.LabelFrame(wrapper, text="Hoster Login / API Key", padding=10)
hosts_frame.pack(fill=tk.X)
for idx, host in enumerate(self.uploaders.keys()):
enabled = tk.BooleanVar(value=True if host in ("doodstream.com", "voe.sx") else False)
credential = tk.StringVar()
chk = ttk.Checkbutton(hosts_frame, text=host, variable=enabled)
chk.grid(row=idx, column=0, sticky=W, padx=(0, 10), pady=4)
entry = ttk.Entry(hosts_frame, width=70, textvariable=credential, show="*")
entry.grid(row=idx, column=1, sticky="we", pady=4)
self.host_rows[host] = HostRow(host=host, enabled=enabled, credential=credential)
hosts_frame.columnconfigure(1, weight=1)
files_frame = ttk.LabelFrame(wrapper, text="Dateien", padding=10)
files_frame.pack(fill=tk.BOTH, expand=False, pady=(10, 0))
btn_row = ttk.Frame(files_frame)
btn_row.pack(fill=tk.X)
ttk.Button(btn_row, text="Dateien waehlen", command=self._pick_files).pack(side=LEFT)
ttk.Button(btn_row, text="Auswahl loeschen", command=self._clear_files).pack(side=LEFT, padx=8)
self.upload_btn = ttk.Button(btn_row, text="Upload starten", command=self._start_upload)
self.upload_btn.pack(side=RIGHT)
self.file_list = tk.Listbox(files_frame, height=8)
self.file_list.pack(fill=tk.X, pady=(8, 0))
output_pane = ttk.PanedWindow(wrapper, orient=tk.HORIZONTAL)
output_pane.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
log_frame = ttk.LabelFrame(output_pane, text="Log", padding=8)
links_frame = ttk.LabelFrame(output_pane, text="Output Links", padding=8)
output_pane.add(log_frame, weight=1)
output_pane.add(links_frame, weight=1)
self.log_text = tk.Text(log_frame, height=14, wrap=tk.WORD)
self.log_text.pack(fill=tk.BOTH, expand=True)
self.links_text = tk.Text(links_frame, height=14, wrap=tk.WORD)
self.links_text.pack(fill=tk.BOTH, expand=True)
action_row = ttk.Frame(wrapper)
action_row.pack(fill=tk.X, pady=(8, 0))
ttk.Button(action_row, text="Links kopieren", command=self._copy_links).pack(side=LEFT)
ttk.Button(action_row, text="Config speichern", command=self._save_config).pack(side=LEFT, padx=8)
def _pick_files(self) -> None:
selected = filedialog.askopenfilenames(title="Dateien zum Upload auswaehlen")
if not selected:
return
for item in selected:
path = Path(item)
if path not in self.files:
self.files.append(path)
self.file_list.insert(END, str(path))
def _clear_files(self) -> None:
self.files = []
self.file_list.delete(0, END)
def _copy_links(self) -> None:
text = self.links_text.get("1.0", END).strip()
if not text:
messagebox.showinfo("Info", "Noch keine Links vorhanden.")
return
self.root.clipboard_clear()
self.root.clipboard_append(text)
self.root.update_idletasks()
messagebox.showinfo("Fertig", "Links in Zwischenablage kopiert.")
def _append_log(self, line: str) -> None:
self.log_text.insert(END, line + "\n")
self.log_text.see(END)
def _append_result(self, result: UploadResult) -> None:
lines = [
f"[{result.host}] {result.file_path.name}",
f" file_code: {result.file_code or '-'}",
f" download: {result.download_url or '-'}",
f" embed: {result.embed_url or '-'}",
"",
]
for line in lines:
self.link_lines.append(line)
self.links_text.delete("1.0", END)
self.links_text.insert("1.0", "\n".join(self.link_lines).strip() + "\n")
def _save_config(self) -> None:
payload = {
"hosts": {
host: {
"enabled": row.enabled.get(),
"credential": row.credential.get(),
}
for host, row in self.host_rows.items()
}
}
CONFIG_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
messagebox.showinfo("Gespeichert", f"Config gespeichert: {CONFIG_PATH}")
def _load_config(self) -> None:
if not CONFIG_PATH.exists():
return
try:
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return
hosts = data.get("hosts", {})
if not isinstance(hosts, dict):
return
for host, row in self.host_rows.items():
host_cfg = hosts.get(host, {})
if not isinstance(host_cfg, dict):
continue
if "enabled" in host_cfg:
row.enabled.set(bool(host_cfg["enabled"]))
if "credential" in host_cfg and isinstance(host_cfg["credential"], str):
row.credential.set(host_cfg["credential"])
def _start_upload(self) -> None:
enabled_hosts = [row for row in self.host_rows.values() if row.enabled.get()]
if not enabled_hosts:
messagebox.showwarning("Fehlt", "Bitte mindestens einen Hoster aktivieren.")
return
if not self.files:
messagebox.showwarning("Fehlt", "Bitte mindestens eine Datei auswaehlen.")
return
self.upload_btn.config(state=tk.DISABLED)
worker = threading.Thread(target=self._run_upload, args=(enabled_hosts, list(self.files)), daemon=True)
worker.start()
def _run_upload(self, enabled_hosts: list[HostRow], files: list[Path]) -> None:
self.log_queue.put("Upload gestartet...")
ok_count = 0
fail_count = 0
for host_row in enabled_hosts:
uploader = self.uploaders[host_row.host]
credential = host_row.credential.get().strip()
if not credential:
self.log_queue.put(f"[{host_row.host}] uebersprungen: Kein Login/API Key hinterlegt")
fail_count += len(files)
continue
for file_path in files:
self.log_queue.put(f"[{host_row.host}] Upload: {file_path.name}")
try:
result = uploader.upload_file(file_path, credential)
self.root.after(0, self._append_result, result)
self.log_queue.put(f"[{host_row.host}] OK: {file_path.name}")
ok_count += 1
except (UploadError, OSError, ValueError) as exc:
self.log_queue.put(f"[{host_row.host}] FEHLER: {file_path.name} -> {exc}")
fail_count += 1
except Exception as exc:
self.log_queue.put(f"[{host_row.host}] FEHLER (unerwartet): {file_path.name} -> {exc}")
fail_count += 1
self.log_queue.put(f"Fertig. Erfolgreich: {ok_count}, Fehler: {fail_count}")
self.root.after(0, lambda: self.upload_btn.config(state=tk.NORMAL))
def _poll_logs(self) -> None:
while True:
try:
line = self.log_queue.get_nowait()
self._append_log(line)
except queue.Empty:
break
self.root.after(120, self._poll_logs)
def main() -> None:
root = tk.Tk()
style = ttk.Style(root)
if "vista" in style.theme_names():
style.theme_use("vista")
app = UploaderApp(root)
root.mainloop()
if __name__ == "__main__":
main()

240
electron-config.json Normal file
View File

@ -0,0 +1,240 @@
{
"hosters": {
"doodstream.com": {
"enabled": true,
"apiKey": "480618be3tgxt6xg1vkjwg"
},
"voe.sx": {
"enabled": true,
"apiKey": "exZEXqkwEnb8eLR79eUI6WVt3JYGFzAfuPsjuGp2nAn7NATGaYhY86NVK5EX1PzD"
},
"vidmoly.me": {
"enabled": true,
"authType": "login",
"username": "bariusgariusdi",
"password": "Paluffel123!"
},
"byse.sx": {
"enabled": true,
"apiKey": "83124r74v61t9dmojm4gz"
}
},
"history": [
{
"id": "batch-1771639560711",
"timestamp": "2026-02-21T02:06:04.634Z",
"total": 3,
"succeeded": 1,
"failed": 2,
"files": [
{
"name": "ssstwitter.com_1770829061540.mp4",
"size": 7799235,
"results": [
{
"hoster": "doodstream.com",
"status": "error",
"error": "Invalid URL",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "byse.sx",
"status": "error",
"error": "Kein Upload-Server erhalten. API-Key pruefen.",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/nnxl9k1bsmpj",
"embed_url": "https://voe.sx/e/nnxl9k1bsmpj",
"file_code": "nnxl9k1bsmpj"
}
]
}
]
},
{
"id": "batch-1771639617785",
"timestamp": "2026-02-21T02:07:01.083Z",
"total": 4,
"succeeded": 1,
"failed": 3,
"files": [
{
"name": "ssstwitter.com_1770829061540.mp4",
"size": 7799235,
"results": [
{
"hoster": "vidmoly.me",
"status": "error",
"error": "maxRedirections is not supported, use the redirect interceptor",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "byse.sx",
"status": "error",
"error": "Kein Upload-Server erhalten. API-Key pruefen.",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "doodstream.com",
"status": "error",
"error": "Invalid URL",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/ujoqyizmrayw",
"embed_url": "https://voe.sx/e/ujoqyizmrayw",
"file_code": "ujoqyizmrayw"
}
]
}
]
},
{
"id": "batch-1771639907565",
"timestamp": "2026-02-21T02:13:33.560Z",
"total": 4,
"succeeded": 3,
"failed": 1,
"files": [
{
"name": "video_1770829348221_0hmfi8.mp4",
"size": 107220796,
"results": [
{
"hoster": "vidmoly.me",
"status": "error",
"error": "Vidmoly Upload-Ergebnis: Kein Download-Link gefunden",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/f38bgbhvia4x",
"embed_url": "https://voe.sx/e/f38bgbhvia4x",
"file_code": "f38bgbhvia4x"
},
{
"hoster": "byse.sx",
"status": "done",
"download_url": "https://byse.sx/zwbsud9yjxks",
"embed_url": "https://byse.sx/e/zwbsud9yjxks",
"file_code": "zwbsud9yjxks"
},
{
"hoster": "doodstream.com",
"status": "done",
"download_url": "https://dsvplay.com/d/cv1y50vfrf7f",
"embed_url": "https://dsvplay.com/e/cv1y50vfrf7f",
"file_code": "cv1y50vfrf7f"
}
]
}
]
},
{
"id": "batch-1771640325234",
"timestamp": "2026-02-21T02:18:52.471Z",
"total": 4,
"succeeded": 2,
"failed": 2,
"files": [
{
"name": "ssstwitter.com_1770829061540.mp4",
"size": 7799235,
"results": [
{
"hoster": "doodstream.com",
"status": "error",
"error": "Invalid URL",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/y4zhied9n4f5",
"embed_url": "https://voe.sx/e/y4zhied9n4f5",
"file_code": "y4zhied9n4f5"
},
{
"hoster": "vidmoly.me",
"status": "error",
"error": "Vidmoly Upload-Ergebnis: Kein Download-Link gefunden",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "byse.sx",
"status": "done",
"download_url": "https://byse.sx/3caubwbj6jxu",
"embed_url": "https://byse.sx/e/3caubwbj6jxu",
"file_code": "3caubwbj6jxu"
}
]
}
]
},
{
"id": "batch-1771643316134",
"timestamp": "2026-02-21T03:09:10.532Z",
"total": 4,
"succeeded": 4,
"failed": 0,
"files": [
{
"name": "ssstwitter.com_1770829061540.mp4",
"size": 7799235,
"results": [
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/juoamb17cdea",
"embed_url": "https://voe.sx/e/juoamb17cdea",
"file_code": "juoamb17cdea"
},
{
"hoster": "byse.sx",
"status": "done",
"download_url": "https://byse.sx/mu8p6ikpsabf",
"embed_url": "https://byse.sx/e/mu8p6ikpsabf",
"file_code": "mu8p6ikpsabf"
},
{
"hoster": "vidmoly.me",
"status": "done",
"download_url": "https://vidmoly.me/w/7460ei78oj22",
"embed_url": "https://vidmoly.me/embed-7460ei78oj22.html",
"file_code": "7460ei78oj22"
},
{
"hoster": "doodstream.com",
"status": "done",
"download_url": "https://dsvplay.com/d/l4rm1kbpkgt0",
"embed_url": "https://dsvplay.com/e/l4rm1kbpkgt0",
"file_code": "l4rm1kbpkgt0"
}
]
}
]
}
]
}

208
hosters.py Normal file
View File

@ -0,0 +1,208 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import requests
class UploadError(Exception):
pass
@dataclass
class UploadResult:
host: str
file_path: Path
download_url: str | None
embed_url: str | None
file_code: str | None
raw: dict[str, Any]
class BaseUploader:
host_name = "base"
def upload_file(self, file_path: Path, credential: str) -> UploadResult:
raise NotImplementedError
@staticmethod
def _get_json(session: requests.Session, url: str, timeout: int = 45) -> dict[str, Any]:
response = session.get(url, timeout=timeout)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and data.get("status") in (401, 403, 429, 500):
raise UploadError(str(data.get("msg") or data.get("message") or data))
return data
class DoodstreamUploader(BaseUploader):
host_name = "doodstream.com"
def __init__(self, api_base: str = "https://doodapi.co") -> None:
self.api_base = api_base.rstrip("/")
def upload_file(self, file_path: Path, credential: str) -> UploadResult:
key = credential.strip()
if not key:
raise UploadError("Doodstream API Key fehlt.")
with requests.Session() as session:
server_data = self._get_json(session, f"{self.api_base}/api/upload/server?key={key}")
upload_url = (server_data.get("result") or "").strip()
if not upload_url:
raise UploadError("Kein Upload-Server von Doodstream erhalten.")
with file_path.open("rb") as stream:
files = {"file": (file_path.name, stream)}
form = {"api_key": key}
target = f"{upload_url}?{key}"
response = session.post(target, data=form, files=files, timeout=1800)
response.raise_for_status()
payload = response.json()
item = None
result = payload.get("result")
if isinstance(result, list) and result:
item = result[0]
elif isinstance(result, dict):
item = result
else:
item = {}
return UploadResult(
host=self.host_name,
file_path=file_path,
download_url=item.get("download_url") or item.get("protected_dl"),
embed_url=item.get("protected_embed"),
file_code=item.get("filecode") or item.get("file_code"),
raw=payload,
)
class VoeUploader(BaseUploader):
host_name = "voe.sx"
def __init__(self, api_base: str = "https://voe.sx") -> None:
self.api_base = api_base.rstrip("/")
def upload_file(self, file_path: Path, credential: str) -> UploadResult:
key = credential.strip()
if not key:
raise UploadError("VOE API Key fehlt.")
with requests.Session() as session:
server_data = self._get_json(session, f"{self.api_base}/api/upload/server?key={key}")
upload_url = (server_data.get("result") or "").strip()
if not upload_url:
raise UploadError("Kein Upload-Server von VOE erhalten.")
with file_path.open("rb") as stream:
files = {"file": (file_path.name, stream)}
target = f"{upload_url}?key={key}"
response = session.post(target, files=files, timeout=1800)
response.raise_for_status()
payload = response.json()
file_code = (
payload.get("file", {}).get("file_code")
if isinstance(payload.get("file"), dict)
else None
)
download = f"https://voe.sx/{file_code}" if file_code else None
embed = f"https://voe.sx/e/{file_code}" if file_code else None
return UploadResult(
host=self.host_name,
file_path=file_path,
download_url=download,
embed_url=embed,
file_code=file_code,
raw=payload,
)
class GenericApiUploader(BaseUploader):
"""Tries API style used by Dood/VOE clones."""
def __init__(self, host_name: str, base_url: str) -> None:
self.host_name = host_name
self.base_url = base_url.rstrip("/")
def _build_links(self, payload: dict[str, Any], file_code: str | None) -> tuple[str | None, str | None]:
if isinstance(payload.get("result"), dict):
result = payload["result"]
return (
result.get("download_url") or result.get("url") or result.get("protected_download"),
result.get("embed_url") or result.get("protected_embed"),
)
if isinstance(payload.get("result"), list) and payload["result"]:
item = payload["result"][0]
if isinstance(item, dict):
return (
item.get("download_url") or item.get("url") or item.get("protected_download"),
item.get("embed_url") or item.get("protected_embed"),
)
if file_code:
return (f"{self.base_url}/{file_code}", f"{self.base_url}/e/{file_code}")
return (None, None)
def upload_file(self, file_path: Path, credential: str) -> UploadResult:
key = credential.strip()
if not key:
raise UploadError(f"{self.host_name}: API Key fehlt.")
with requests.Session() as session:
candidates = [
f"{self.base_url}/api/upload/server?key={key}",
f"{self.base_url}/api/v1/upload/server?key={key}",
]
server_data: dict[str, Any] | None = None
for url in candidates:
try:
server_data = self._get_json(session, url)
if server_data.get("result"):
break
except Exception:
continue
if not server_data or not server_data.get("result"):
raise UploadError(
f"{self.host_name}: Kein kompatibler API-Endpunkt gefunden. "
"Bitte API-Doku/Key pruefen."
)
upload_url = str(server_data["result"]).strip()
with file_path.open("rb") as stream:
files = {"file": (file_path.name, stream)}
data = {"api_key": key, "key": key}
response = session.post(f"{upload_url}?key={key}", data=data, files=files, timeout=1800)
response.raise_for_status()
payload = response.json()
file_code = None
if isinstance(payload.get("file"), dict):
file_code = payload["file"].get("file_code")
if not file_code and isinstance(payload.get("result"), dict):
file_code = payload["result"].get("filecode") or payload["result"].get("file_code")
download, embed = self._build_links(payload, file_code)
return UploadResult(
host=self.host_name,
file_path=file_path,
download_url=download,
embed_url=embed,
file_code=file_code,
raw=payload,
)
def build_uploaders() -> dict[str, BaseUploader]:
return {
"doodstream.com": DoodstreamUploader(),
"voe.sx": VoeUploader(),
"vidmoly.me": GenericApiUploader("vidmoly.me", "https://vidmoly.me"),
"byse.sx": GenericApiUploader("byse.sx", "https://byse.sx"),
}

70
lib/config-store.js Normal file
View File

@ -0,0 +1,70 @@
const fs = require('fs');
const path = require('path');
const DEFAULTS = {
hosters: {
'doodstream.com': { enabled: true, apiKey: '' },
'voe.sx': { enabled: true, apiKey: '' },
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' },
'byse.sx': { enabled: true, apiKey: '' }
},
history: []
};
const MAX_HISTORY = 100;
class ConfigStore {
constructor(app) {
const dir = app && app.isPackaged
? app.getPath('userData')
: path.join(__dirname, '..');
this.filePath = path.join(dir, 'electron-config.json');
}
load() {
try {
const raw = fs.readFileSync(this.filePath, 'utf-8');
const data = JSON.parse(raw);
// Merge with defaults so new hosters are always present
const hosters = { ...DEFAULTS.hosters };
for (const [name, val] of Object.entries(data.hosters || {})) {
if (hosters[name]) {
hosters[name] = { ...hosters[name], ...val };
}
}
return { hosters, history: data.history || [] };
} catch {
return JSON.parse(JSON.stringify(DEFAULTS));
}
}
save(config) {
const current = this.load();
// Only update hosters, keep history
current.hosters = config.hosters || current.hosters;
fs.writeFileSync(this.filePath, JSON.stringify(current, null, 2), 'utf-8');
}
loadHistory() {
const config = this.load();
return config.history || [];
}
appendHistory(entry) {
const config = this.load();
config.history.push(entry);
// Cap at MAX_HISTORY
if (config.history.length > MAX_HISTORY) {
config.history = config.history.slice(-MAX_HISTORY);
}
fs.writeFileSync(this.filePath, JSON.stringify(config, null, 2), 'utf-8');
}
clearHistory() {
const config = this.load();
config.history = [];
fs.writeFileSync(this.filePath, JSON.stringify(config, null, 2), 'utf-8');
}
}
module.exports = ConfigStore;

375
lib/hosters.js Normal file
View File

@ -0,0 +1,375 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
const UPLOAD_TIMEOUT = 1800000; // 30 minutes
const API_TIMEOUT = 45000; // 45 seconds
const SERVER_RETRY_ATTEMPTS = 6;
const SERVER_RETRY_DELAY_MS = 2500;
const LAST_UPLOAD_SERVERS = new Map();
function appendRawQuery(url, rawQuery) {
const parsed = new URL(url);
const cleanQuery = String(rawQuery || '').trim().replace(/^\?+/, '');
if (!cleanQuery) return parsed.toString();
if (parsed.search && parsed.search.length > 1) {
parsed.search = `${parsed.search.slice(1)}&${cleanQuery}`;
} else {
parsed.search = cleanQuery;
}
return parsed.toString();
}
function appendKeyParam(url, key) {
const parsed = new URL(url);
parsed.searchParams.set('key', key);
return parsed.toString();
}
// Hoster definitions - based on official API docs
const HOSTER_CONFIGS = {
'doodstream.com': {
apiBase: 'https://doodapi.co',
serverEndpoints: ['/api/upload/server'],
fallbackUploadServers: ['https://tr1128ve.cloudatacdn.com/upload/01'],
buildUploadUrl: (url, key) => appendRawQuery(url, key),
formFields: (key) => ({ api_key: key }),
parseResult: parseDoodstreamResult
},
'voe.sx': {
apiBase: 'https://voe.sx',
serverEndpoints: ['/api/upload/server'],
buildUploadUrl: (url, key) => appendKeyParam(url, key),
formFields: () => ({}),
parseResult: parseVoeResult
},
'byse.sx': {
apiBase: 'https://api.byse.sx',
serverEndpoints: ['/upload/server'],
buildUploadUrl: (url, key) => appendKeyParam(url, key),
formFields: (key) => ({ key }),
parseResult: parseByseResult
}
};
function normalizeAbsoluteUrl(raw, apiBase) {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (!trimmed || /^\[object\s+Object\]$/i.test(trimmed)) return null;
let candidate = trimmed;
if (candidate.startsWith('//')) {
candidate = `https:${candidate}`;
} else if (candidate.startsWith('/')) {
try {
candidate = new URL(candidate, apiBase).href;
} catch {
return null;
}
} else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(candidate)) {
candidate = `https://${candidate.replace(/^\/+/, '')}`;
}
try {
const parsed = new URL(candidate);
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
return parsed.href;
} catch {
return null;
}
}
function collectUploadUrlCandidates(value, out = []) {
if (typeof value === 'string') {
out.push(value);
return out;
}
if (Array.isArray(value)) {
for (const entry of value) collectUploadUrlCandidates(entry, out);
return out;
}
if (value && typeof value === 'object') {
const preferredKeys = ['upload_url', 'uploadUrl', 'url', 'server', 'srv', 'result'];
for (const key of preferredKeys) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
collectUploadUrlCandidates(value[key], out);
}
}
for (const nested of Object.values(value)) {
if (typeof nested === 'string') out.push(nested);
}
}
return out;
}
function extractUploadServerUrl(payload, apiBase) {
const source = payload && Object.prototype.hasOwnProperty.call(payload, 'result')
? payload.result
: payload;
const candidates = collectUploadUrlCandidates(source, []);
for (const candidate of candidates) {
const normalized = normalizeAbsoluteUrl(candidate, apiBase);
if (normalized) return normalized;
}
return null;
}
function shouldRetryServerLookup(message) {
const msg = String(message || '').toLowerCase();
if (!msg) return true;
if (msg.includes('invalid') && msg.includes('key')) return false;
if (msg.includes('unauthorized') || msg.includes('forbidden')) return false;
if (msg.includes('no servers available')) return true;
if (msg.includes('temporar') || msg.includes('busy') || msg.includes('try again')) return true;
return true;
}
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (signal) signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
if (signal) signal.removeEventListener('abort', onAbort);
const err = new Error('Aborted');
err.name = 'AbortError';
reject(err);
}
if (signal) {
if (signal.aborted) return onAbort();
signal.addEventListener('abort', onAbort);
}
});
}
// --- Result parsers ---
// Doodstream: { result: [{ download_url, protected_embed, filecode, protected_dl }] }
function parseDoodstreamResult(payload) {
let item = {};
const result = payload.result;
if (Array.isArray(result) && result.length > 0) {
item = result[0];
} else if (result && typeof result === 'object') {
item = result;
}
return {
download_url: item.download_url || item.protected_dl || null,
embed_url: item.protected_embed || null,
file_code: item.filecode || item.file_code || null
};
}
// VOE: { file: { file_code } }
function parseVoeResult(payload) {
const file_code = (payload.file && typeof payload.file === 'object')
? payload.file.file_code
: null;
return {
download_url: file_code ? `https://voe.sx/${file_code}` : null,
embed_url: file_code ? `https://voe.sx/e/${file_code}` : null,
file_code
};
}
// Byse: { files: [{ filecode, filename, status }] }
function parseByseResult(payload) {
let file_code = null;
// Primary: files array (per official Byse API docs)
if (Array.isArray(payload.files) && payload.files.length > 0) {
file_code = payload.files[0].filecode || payload.files[0].file_code;
}
// Fallback: result object
if (!file_code && payload.result) {
const result = payload.result;
if (Array.isArray(result) && result.length > 0) {
file_code = result[0].filecode || result[0].file_code;
} else if (typeof result === 'object') {
file_code = result.filecode || result.file_code;
}
}
return {
download_url: file_code ? `https://byse.sx/${file_code}` : null,
embed_url: file_code ? `https://byse.sx/e/${file_code}` : null,
file_code
};
}
// --- Multipart upload with progress ---
function buildMultipart(filePath, formFields) {
const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex');
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
let preamble = '';
for (const [key, value] of Object.entries(formFields)) {
preamble += `--${boundary}\r\n`;
preamble += `Content-Disposition: form-data; name="${key}"\r\n\r\n`;
preamble += `${value}\r\n`;
}
preamble += `--${boundary}\r\n`;
preamble += `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`;
preamble += `Content-Type: application/octet-stream\r\n\r\n`;
const epilogue = `\r\n--${boundary}--\r\n`;
const preambleBuf = Buffer.from(preamble, 'utf-8');
const epilogueBuf = Buffer.from(epilogue, 'utf-8');
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
return { boundary, preambleBuf, epilogueBuf, totalSize, fileSize };
}
function createUploadBody(filePath, formFields, onProgress) {
const { boundary, preambleBuf, epilogueBuf, totalSize, fileSize } = buildMultipart(filePath, formFields);
let bytesRead = 0;
const CHUNK_SIZE = 256 * 1024;
async function* generate() {
yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
for await (const chunk of fileStream) {
bytesRead += chunk.length;
yield chunk;
if (onProgress) onProgress(bytesRead, fileSize);
}
yield epilogueBuf;
}
return { iterable: generate(), boundary, totalSize };
}
// --- API helper using built-in fetch (follows redirects automatically) ---
async function apiGet(url, signal) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT);
if (signal) signal.addEventListener('abort', () => controller.abort());
try {
const res = await fetch(url, {
method: 'GET',
signal: controller.signal,
redirect: 'follow'
});
const data = await res.json();
if (data.status && [401, 403, 429, 500].includes(data.status)) {
throw new Error(data.msg || data.message || JSON.stringify(data));
}
return data;
} finally {
clearTimeout(timeout);
}
}
// --- Main upload function ---
async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
let lastMessage = '';
for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) {
for (const endpoint of hosterConfig.serverEndpoints) {
const url = `${hosterConfig.apiBase}${endpoint}?key=${apiKey}`;
try {
const data = await apiGet(url, signal);
const uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase);
if (uploadUrl) {
LAST_UPLOAD_SERVERS.set(hosterName, uploadUrl);
return uploadUrl;
}
const apiMessage = data && (data.msg || data.message)
? String(data.msg || data.message).trim()
: '';
if (apiMessage) lastMessage = apiMessage;
} catch (err) {
if (err.name === 'AbortError') throw err;
if (err.message) lastMessage = err.message;
}
}
if (attempt < SERVER_RETRY_ATTEMPTS && shouldRetryServerLookup(lastMessage)) {
await sleep(SERVER_RETRY_DELAY_MS, signal);
continue;
}
break;
}
const cachedServer = LAST_UPLOAD_SERVERS.get(hosterName);
if (cachedServer && shouldRetryServerLookup(lastMessage)) {
return cachedServer;
}
if (shouldRetryServerLookup(lastMessage) && Array.isArray(hosterConfig.fallbackUploadServers)) {
for (const fallback of hosterConfig.fallbackUploadServers) {
const normalized = normalizeAbsoluteUrl(fallback, hosterConfig.apiBase);
if (normalized) {
LAST_UPLOAD_SERVERS.set(hosterName, normalized);
return normalized;
}
}
}
if (lastMessage) {
throw new Error(`Kein Upload-Server erhalten: ${lastMessage}`);
}
throw new Error('Kein Upload-Server erhalten. API-Key pruefen.');
}
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal) {
const config = HOSTER_CONFIGS[hosterName];
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
// Step 1: Get upload server
const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal);
// Step 2: Upload file with progress
const targetUrl = config.buildUploadUrl(uploadUrl, apiKey);
const formFields = config.formFields(apiKey);
const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress);
const { body, statusCode } = await request(targetUrl, {
method: 'POST',
body: iterable,
signal,
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize)
},
headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT
});
const payload = await body.json();
if (payload.status && [401, 403, 429, 500].includes(payload.status)) {
throw new Error(payload.msg || payload.message || JSON.stringify(payload));
}
// Step 3: Parse result
return config.parseResult(payload);
}
module.exports = { uploadFile, HOSTER_CONFIGS };

149
lib/upload-manager.js Normal file
View File

@ -0,0 +1,149 @@
const { EventEmitter } = require('events');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { uploadFile } = require('./hosters');
const VidmolyUploader = require('./vidmoly-upload');
class UploadManager extends EventEmitter {
constructor() {
super();
this.abortController = new AbortController();
this.running = false;
}
async startBatch(tasks) {
this.running = true;
this.abortController = new AbortController();
const { signal } = this.abortController;
const batchId = `batch-${Date.now()}`;
const results = new Map(); // fileName -> { name, size, results: [] }
// Initialize result map per file
for (const task of tasks) {
const fileName = path.basename(task.file);
if (!results.has(task.file)) {
let size = 0;
try { size = fs.statSync(task.file).size; } catch {}
results.set(task.file, { name: fileName, size, results: [] });
}
}
// Build upload promises
const promises = tasks.map(async (task) => {
const uploadId = crypto.randomBytes(8).toString('hex');
const fileName = path.basename(task.file);
let fileSize = 0;
try { fileSize = fs.statSync(task.file).size; } catch {}
// Emit initial status
this.emit('progress', {
uploadId,
fileName,
hoster: task.hoster,
status: 'getting-server',
progress: 0,
bytesUploaded: 0,
bytesTotal: fileSize,
error: null,
result: null
});
try {
let result;
const progressCb = (bytesUploaded, bytesTotal) => {
this.emit('progress', {
uploadId,
fileName,
hoster: task.hoster,
status: 'uploading',
progress: bytesTotal > 0 ? bytesUploaded / bytesTotal : 0,
bytesUploaded,
bytesTotal,
error: null,
result: null
});
};
if (task.hoster === 'vidmoly.me' && task.username) {
// Vidmoly: login-based upload
const vidmoly = new VidmolyUploader();
await vidmoly.login(task.username, task.password);
result = await vidmoly.upload(task.file, progressCb, signal);
} else {
result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal);
}
this.emit('progress', {
uploadId,
fileName,
hoster: task.hoster,
status: 'done',
progress: 1,
bytesUploaded: fileSize,
bytesTotal: fileSize,
error: null,
result
});
results.get(task.file).results.push({
hoster: task.hoster,
status: 'done',
...result
});
} catch (err) {
const errorMsg = err.name === 'AbortError' ? 'Abgebrochen' : err.message;
this.emit('progress', {
uploadId,
fileName,
hoster: task.hoster,
status: 'error',
progress: 0,
bytesUploaded: 0,
bytesTotal: fileSize,
error: errorMsg,
result: null
});
results.get(task.file).results.push({
hoster: task.hoster,
status: 'error',
error: errorMsg,
download_url: null,
embed_url: null,
file_code: null
});
}
});
await Promise.allSettled(promises);
this.running = false;
const files = Array.from(results.values());
const total = tasks.length;
const succeeded = files.reduce((n, f) => n + f.results.filter(r => r.status === 'done').length, 0);
const summary = {
id: batchId,
timestamp: new Date().toISOString(),
total,
succeeded,
failed: total - succeeded,
files
};
this.emit('batch-done', summary);
}
cancel() {
if (this.running) {
this.abortController.abort();
this.running = false;
}
}
}
module.exports = UploadManager;

517
lib/vidmoly-upload.js Normal file
View File

@ -0,0 +1,517 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
const BASE_URL = 'https://vidmoly.me';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
const UPLOAD_TIMEOUT = 1800000; // 30 min
const RESULT_POLL_ATTEMPTS = 10;
const RESULT_POLL_DELAY_MS = 2000;
/**
* XFileSharing-based upload for Vidmoly (login + form upload)
*/
class VidmolyUploader {
constructor() {
this.cookies = new Map();
}
_cookieHeader() {
return Array.from(this.cookies.entries())
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
_parseCookiesFromHeaders(headers) {
// Handle both undici response headers and fetch Headers
let setCookies;
if (typeof headers.getSetCookie === 'function') {
setCookies = headers.getSetCookie();
} else if (headers['set-cookie']) {
setCookies = Array.isArray(headers['set-cookie']) ? headers['set-cookie'] : [headers['set-cookie']];
} else {
return;
}
for (const raw of setCookies) {
const pair = raw.split(';')[0];
const eq = pair.indexOf('=');
if (eq > 0) {
this.cookies.set(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
}
}
}
/**
* Simple GET/POST using built-in fetch (handles redirects)
*/
async _fetch(url, opts = {}) {
const headers = {
'User-Agent': USER_AGENT,
...(opts.headers || {})
};
if (this.cookies.size > 0) {
headers['Cookie'] = this._cookieHeader();
}
const res = await fetch(url, {
...opts,
headers,
redirect: 'manual' // handle manually to capture cookies from redirect responses
});
this._parseCookiesFromHeaders(res.headers);
// Follow redirects manually (to capture cookies at each hop)
if ([301, 302, 303, 307, 308].includes(res.status)) {
const location = res.headers.get('location');
if (location) {
const nextUrl = new URL(location, url).href;
return this._fetch(nextUrl, { ...opts, method: 'GET', body: undefined });
}
}
return res;
}
/**
* Login to Vidmoly
*/
async login(username, password) {
// First GET the main page to get initial cookies
const initRes = await this._fetch(BASE_URL);
await initRes.text();
// POST login
const loginData = new URLSearchParams({
op: 'login',
login: username,
password: password,
redirect: ''
});
const res = await this._fetch(BASE_URL, {
method: 'POST',
body: loginData.toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': BASE_URL
}
});
const body = await res.text();
if (body.includes('Incorrect Login or Password')) {
throw new Error('Vidmoly Login fehlgeschlagen: Falscher Username oder Passwort');
}
// Check for login cookie
const hasSession = this.cookies.has('login') || this.cookies.has('xfsts') ||
this.cookies.size > 1;
if (!hasSession) {
throw new Error('Vidmoly Login fehlgeschlagen: Keine Session erhalten');
}
}
/**
* Get upload form parameters from the upload page
*/
async getUploadParams() {
const res = await this._fetch(`${BASE_URL}/?op=upload`);
const html = await res.text();
// Parse hidden form fields from XFS upload form
const params = {};
const inputRegex = /<input[^>]*type=["']hidden["'][^>]*>/gi;
let match;
while ((match = inputRegex.exec(html)) !== null) {
const tag = match[0];
const nameMatch = tag.match(/name=["']([^"']+)["']/);
const valueMatch = tag.match(/value=["']([^"']*?)["']/);
if (nameMatch) {
params[nameMatch[1]] = valueMatch ? valueMatch[1] : '';
}
}
// Extract form action
const formMatch = html.match(/<form[^>]*id=["']?file_upload["']?[^>]*action=["']([^"']+)["']/i)
|| html.match(/<form[^>]*enctype=["']multipart\/form-data["'][^>]*action=["']([^"']+)["']/i)
|| html.match(/<form[^>]*action=["']([^"']+)["'][^>]*enctype=["']multipart\/form-data["']/i);
let uploadUrl = null;
if (formMatch) {
uploadUrl = formMatch[1];
} else if (params.srv_tmp_url) {
uploadUrl = params.srv_tmp_url;
}
if (!uploadUrl) {
const cgiMatch = html.match(/(https?:\/\/[^"'\s]+\/cgi-bin\/upload\.cgi[^"'\s]*)/i)
|| html.match(/(https?:\/\/[^"'\s]+\/upload\/\d+)/i);
if (cgiMatch) uploadUrl = cgiMatch[1];
}
if (!uploadUrl) {
throw new Error('Vidmoly Upload-URL nicht gefunden. Bist du eingeloggt?');
}
let fileFieldName = 'file';
const fileInputMatch = html.match(/<input[^>]*type=["']file["'][^>]*name=["']([^"']+)["']/i)
|| html.match(/<input[^>]*name=["']([^"']+)["'][^>]*type=["']file["']/i);
if (fileInputMatch && fileInputMatch[1]) {
fileFieldName = fileInputMatch[1].trim();
}
return { uploadUrl, params, fileFieldName };
}
/**
* Upload a file to Vidmoly (uses undici.request for streaming progress)
*/
async upload(filePath, onProgress, signal) {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
const baselineCodes = await this._captureVmFileCodes();
const { uploadUrl, params, fileFieldName } = await this.getUploadParams();
const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex');
// XFS form fields
const formFields = {};
for (const [k, v] of Object.entries(params)) {
if (!/^file(?:_\d+)?$/i.test(k)) {
formFields[k] = v;
}
}
// Build multipart
let preamble = '';
for (const [key, value] of Object.entries(formFields)) {
preamble += `--${boundary}\r\n`;
preamble += `Content-Disposition: form-data; name="${key}"\r\n\r\n`;
preamble += `${value}\r\n`;
}
preamble += `--${boundary}\r\n`;
preamble += `Content-Disposition: form-data; name="${fileFieldName || 'file'}"; filename="${fileName}"\r\n`;
preamble += `Content-Type: application/octet-stream\r\n\r\n`;
const epilogue = `\r\n--${boundary}--\r\n`;
const preambleBuf = Buffer.from(preamble, 'utf-8');
const epilogueBuf = Buffer.from(epilogue, 'utf-8');
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
let bytesRead = 0;
const CHUNK_SIZE = 256 * 1024;
async function* generate() {
yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
for await (const chunk of fileStream) {
bytesRead += chunk.length;
yield chunk;
if (onProgress) onProgress(bytesRead, fileSize);
}
yield epilogueBuf;
}
// Use undici.request for the upload (streaming body for progress)
const { body, statusCode, headers } = await request(uploadUrl, {
method: 'POST',
body: generate(),
signal,
headers: {
'User-Agent': USER_AGENT,
'Cookie': this._cookieHeader(),
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize),
'Referer': `${BASE_URL}/upload.html`
},
headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT
});
this._parseCookiesFromHeaders(headers || {});
// Check if upload response is a redirect (XFS often redirects to result page)
let resultHtml;
if ([301, 302, 303].includes(statusCode)) {
const location = headers && headers.location;
if (location) {
const resultRes = await this._fetch(new URL(location, uploadUrl).href);
resultHtml = await resultRes.text();
} else {
resultHtml = await body.text();
}
} else {
resultHtml = await body.text();
}
// Try JSON first (some XFS versions return JSON)
try {
const json = JSON.parse(resultHtml);
if (json.files && json.files.length > 0) {
const f = json.files[0];
const code = f.filecode || f.file_code;
return {
download_url: code ? `${BASE_URL}/w/${code}` : null,
embed_url: code ? `${BASE_URL}/embed-${code}.html` : null,
file_code: code
};
}
if (json.result) {
const r = Array.isArray(json.result) ? json.result[0] : json.result;
const code = r.filecode || r.file_code;
return {
download_url: r.download_url || (code ? `${BASE_URL}/w/${code}` : null),
embed_url: r.embed_url || (code ? `${BASE_URL}/embed-${code}.html` : null),
file_code: code
};
}
} catch {}
try {
return this._parseUploadResult(resultHtml);
} catch (primaryErr) {
const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal);
if (fallback) return fallback;
throw primaryErr;
}
}
_normalizeTitle(value) {
return String(value || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z0-9]+/g, '');
}
_scoreVmCandidate(file, expectedTitle) {
if (!file || !file.file_code) return -1;
if (!expectedTitle) return 0;
const title = this._normalizeTitle(file.full_title || file.title_txt || '');
if (!title) return -1;
if (title === expectedTitle) return 120;
if (title.startsWith(expectedTitle) || expectedTitle.startsWith(title)) return 90;
if (title.includes(expectedTitle) || expectedTitle.includes(title)) return 70;
return 0;
}
_buildUrlsFromCode(fileCode) {
const code = String(fileCode || '').trim();
if (!code) return null;
return {
download_url: `${BASE_URL}/w/${code}`,
embed_url: `${BASE_URL}/embed-${code}.html`,
file_code: code
};
}
async _captureVmFileCodes() {
try {
const files = await this._fetchVmList();
return new Set(
files
.map((f) => String(f.file_code || '').trim())
.filter(Boolean)
);
} catch {
return new Set();
}
}
async _fetchVmList() {
const params = new URLSearchParams({
op: 'vm',
api: 'list',
page: '1',
per: '100',
sort: 'date',
order: 'desc',
fld_id: '0'
});
const res = await this._fetch(`${BASE_URL}/?${params.toString()}`);
const body = await res.text();
let payload;
try {
payload = JSON.parse(body);
} catch {
throw new Error('Vidmoly VM API lieferte kein JSON');
}
if (!payload || !Array.isArray(payload.files)) return [];
return payload.files;
}
async _resolveUploadedFileFromVmApi(fileName, baselineCodes, signal) {
const expectedTitle = this._normalizeTitle(path.parse(fileName).name);
for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) {
if (signal && signal.aborted) {
const err = new Error('Aborted');
err.name = 'AbortError';
throw err;
}
let files = [];
try {
files = await this._fetchVmList();
} catch {
files = [];
}
const withCode = files.filter((f) => f && typeof f.file_code === 'string' && f.file_code.trim());
const newFiles = withCode.filter((f) => !baselineCodes.has(f.file_code));
if (newFiles.length > 0) {
let best = null;
let bestScore = -1;
for (const file of newFiles) {
const score = this._scoreVmCandidate(file, expectedTitle);
if (score > bestScore) {
bestScore = score;
best = file;
}
}
if (best && (bestScore > 0 || newFiles.length === 1)) {
return this._buildUrlsFromCode(best.file_code);
}
}
if (expectedTitle) {
let bestMatch = null;
let bestScore = -1;
for (const file of withCode) {
const score = this._scoreVmCandidate(file, expectedTitle);
if (score > bestScore) {
bestScore = score;
bestMatch = file;
}
}
if (bestMatch && bestScore >= 90) {
return this._buildUrlsFromCode(bestMatch.file_code);
}
}
if (attempt < RESULT_POLL_ATTEMPTS - 1) {
await this._sleep(RESULT_POLL_DELAY_MS, signal);
}
}
return null;
}
_sleep(ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (signal) signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
if (signal) signal.removeEventListener('abort', onAbort);
const err = new Error('Aborted');
err.name = 'AbortError';
reject(err);
}
if (signal) {
if (signal.aborted) return onAbort();
signal.addEventListener('abort', onAbort);
}
});
}
_parseUploadResult(html) {
let download_url = null;
let embed_url = null;
let file_code = null;
const fnMatch = html.match(/<(?:input|textarea)[^>]*name=["']fn["'][^>]*(?:value=["']([^"']+)["'])?[^>]*>([^<]*)/i);
if (fnMatch) {
const codeFromFn = (fnMatch[1] || fnMatch[2] || '').trim();
if (/^[a-z0-9]{8,16}$/i.test(codeFromFn)) {
file_code = codeFromFn;
}
}
if (!file_code) {
const fnAltMatch = html.match(/(?:^|[?&])fn=([a-z0-9]{8,16})(?:&|$)/i);
if (fnAltMatch) file_code = fnAltMatch[1];
}
// Vidmoly URL patterns - includes /w/ path format
const linkPatterns = [
/https?:\/\/vidmoly\.[a-z]+\/w\/[a-z0-9]{12}/gi,
/https?:\/\/vidmoly\.[a-z]+\/embed-[a-z0-9]{12}[^\s"']*/gi,
/https?:\/\/vidmoly\.[a-z]+\/[a-z0-9]{12}\.html/gi,
/https?:\/\/vidmoly\.[a-z]+\/[a-z0-9]{12}/gi
];
for (const pattern of linkPatterns) {
const matches = html.match(pattern);
if (matches) {
for (const url of matches) {
if (url.includes('/embed-') || url.includes('/embed/')) {
if (!embed_url) embed_url = url;
} else {
if (!download_url) download_url = url;
}
}
}
}
// Extract file code from URLs
const codeMatch = (download_url || embed_url || '').match(/\/(?:w\/)?([a-z0-9]{12})/i)
|| (download_url || embed_url || '').match(/embed-([a-z0-9]{12})/i);
if (codeMatch) {
file_code = codeMatch[1];
}
// Try input/textarea fields
if (!download_url) {
const inputMatch = html.match(/<(?:input|textarea)[^>]*value=["'](https?:\/\/vidmoly[^"']+)["']/i);
if (inputMatch) {
download_url = inputMatch[1];
const code = download_url.match(/\/(?:w\/)?([a-z0-9]{12})/i);
if (code) file_code = code[1];
}
}
// Try to find file code in any filecode reference
if (!file_code) {
const codeInPage = html.match(/filecode['":\s]+['"]?([a-z0-9]{12})['"]?/i)
|| html.match(/file_code['":\s]+['"]?([a-z0-9]{12})['"]?/i);
if (codeInPage) file_code = codeInPage[1];
}
// Build URLs from file_code
if (file_code && !download_url) {
download_url = `${BASE_URL}/w/${file_code}`;
}
if (file_code && !embed_url) {
embed_url = `${BASE_URL}/embed-${file_code}.html`;
}
if (!download_url && !file_code) {
const errMatch = html.match(/class=["']err["'][^>]*>([^<]+)/i);
const errMsg = errMatch ? errMatch[1].trim() : 'Kein Download-Link gefunden';
throw new Error(`Vidmoly Upload-Ergebnis: ${errMsg}`);
}
return { download_url, embed_url, file_code };
}
}
module.exports = VidmolyUploader;

289
main.js Normal file
View File

@ -0,0 +1,289 @@
const { app, BrowserWindow, ipcMain, dialog, clipboard } = require('electron');
const path = require('path');
const ConfigStore = require('./lib/config-store');
const UploadManager = require('./lib/upload-manager');
const { HOSTER_CONFIGS } = require('./lib/hosters');
const VidmolyUploader = require('./lib/vidmoly-upload');
let mainWindow;
const configStore = new ConfigStore(app);
let uploadManager = null;
const HEALTH_CHECK_TIMEOUT = 25000;
function withTimeout(promise, timeoutMs, label) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} Timeout`));
}, timeoutMs);
promise
.then((result) => {
clearTimeout(timer);
resolve(result);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
function normalizeApiError(payload, fallback) {
if (!payload || typeof payload !== 'object') return fallback;
const msg = String(payload.msg || payload.message || '').trim();
if (msg) return msg;
if (payload.status) return `API Status ${payload.status}`;
return fallback;
}
async function checkDoodstreamHealth(hosterConfig) {
const apiKey = hosterConfig && hosterConfig.apiKey
? String(hosterConfig.apiKey).trim()
: '';
if (!apiKey) {
return { status: 'error', message: 'API Key fehlt' };
}
const apiBase = HOSTER_CONFIGS['doodstream.com'].apiBase;
const accountRes = await fetch(`${apiBase}/api/account/info?key=${encodeURIComponent(apiKey)}`, {
method: 'GET',
redirect: 'follow'
});
const accountPayload = await accountRes.json().catch(() => null);
if (!accountPayload || typeof accountPayload !== 'object') {
return { status: 'error', message: 'Account-Check lieferte kein gueltiges JSON' };
}
if (Number(accountPayload.status || 0) !== 200) {
return {
status: 'error',
message: normalizeApiError(accountPayload, 'Account-Check fehlgeschlagen')
};
}
const serverRes = await fetch(`${apiBase}/api/upload/server?key=${encodeURIComponent(apiKey)}`, {
method: 'GET',
redirect: 'follow'
});
const serverPayload = await serverRes.json().catch(() => null);
if (!serverPayload || typeof serverPayload !== 'object') {
return { status: 'warn', message: 'Upload-Server-Check lieferte kein gueltiges JSON' };
}
const serverResult = serverPayload.result;
if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) {
return { status: 'ok', message: 'API Key gueltig, Upload-Server verfuegbar' };
}
const serverMsg = String(serverPayload.msg || serverPayload.message || '').trim();
if (/no servers available/i.test(serverMsg)) {
return {
status: 'warn',
message: 'API Key gueltig, aktuell kein Server von API (Uploader nutzt Fallback)'
};
}
return {
status: 'warn',
message: serverMsg || 'API Key gueltig, Upload-Server aktuell nicht geliefert'
};
}
async function checkVidmolyHealth(hosterConfig) {
const username = hosterConfig && hosterConfig.username
? String(hosterConfig.username).trim()
: '';
const password = hosterConfig && hosterConfig.password
? String(hosterConfig.password).trim()
: '';
if (!username || !password) {
return { status: 'error', message: 'Username oder Passwort fehlt' };
}
const uploader = new VidmolyUploader();
await uploader.login(username, password);
const { uploadUrl, fileFieldName } = await uploader.getUploadParams();
if (!uploadUrl || !/^https?:\/\//i.test(uploadUrl)) {
return { status: 'error', message: 'Upload-URL wurde nicht erkannt' };
}
return {
status: 'ok',
message: `Login ok, Upload-Form bereit (Dateifeld: ${fileFieldName || 'file'})`
};
}
async function runHosterHealthCheck(config, requestedHosters) {
const allowed = ['doodstream.com', 'vidmoly.me'];
const source = Array.isArray(requestedHosters) && requestedHosters.length > 0
? requestedHosters
: allowed;
const hosters = source
.map((name) => String(name || '').trim())
.filter((name, index, arr) => name && arr.indexOf(name) === index);
const checks = hosters.map(async (hoster) => {
if (!allowed.includes(hoster)) {
return { hoster, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
}
const hosterConfig = config && config.hosters ? config.hosters[hoster] : null;
try {
if (hoster === 'doodstream.com') {
const result = await withTimeout(
checkDoodstreamHealth(hosterConfig),
HEALTH_CHECK_TIMEOUT,
'Doodstream-Check'
);
return { hoster, ...result };
}
if (hoster === 'vidmoly.me') {
const result = await withTimeout(
checkVidmolyHealth(hosterConfig),
HEALTH_CHECK_TIMEOUT,
'Vidmoly-Check'
);
return { hoster, ...result };
}
return { hoster, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
} catch (err) {
return {
hoster,
status: 'error',
message: err && err.message ? err.message : 'Health-Check fehlgeschlagen'
};
}
});
const results = await Promise.all(checks);
return {
checkedAt: new Date().toISOString(),
results
};
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1100,
height: 750,
minWidth: 800,
minHeight: 550,
backgroundColor: '#0f0f1a',
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, 'preload.js')
}
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
app.quit();
});
// --- IPC Handlers ---
ipcMain.handle('get-config', () => {
return configStore.load();
});
ipcMain.handle('save-config', (_event, config) => {
configStore.save(config);
return true;
});
ipcMain.handle('get-history', () => {
return configStore.loadHistory();
});
ipcMain.handle('run-health-check', async (_event, payload) => {
const config = configStore.load();
const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : [];
return runHosterHealthCheck(config, hosters);
});
ipcMain.handle('select-files', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile', 'multiSelections'],
filters: [
{ name: 'Alle Dateien', extensions: ['*'] },
{ name: 'Videos', extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm'] }
]
});
return result.canceled ? null : result.filePaths;
});
ipcMain.handle('start-upload', (_event, payload) => {
const config = configStore.load();
const { files, hosters } = payload;
// Build tasks with credentials
const tasks = [];
for (const file of files) {
for (const hoster of hosters) {
const hosterConfig = config.hosters[hoster];
if (!hosterConfig) continue;
if (hoster === 'vidmoly.me') {
// Vidmoly uses username/password login
if (!hosterConfig.username || !hosterConfig.password) continue;
tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password });
} else {
// Other hosters use API key
if (!hosterConfig.apiKey) continue;
tasks.push({ file, hoster, apiKey: hosterConfig.apiKey });
}
}
}
if (tasks.length === 0) return { error: 'Keine gueltigen Zugangsdaten fuer die gewaehlten Hoster.' };
uploadManager = new UploadManager();
uploadManager.on('progress', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-progress', data);
}
});
uploadManager.on('batch-done', (summary) => {
configStore.appendHistory(summary);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-batch-done', summary);
}
});
uploadManager.startBatch(tasks);
return { started: true, taskCount: tasks.length };
});
ipcMain.handle('cancel-upload', () => {
if (uploadManager) {
uploadManager.cancel();
uploadManager = null;
}
return true;
});
ipcMain.handle('clear-history', () => {
configStore.clearHistory();
return true;
});
ipcMain.handle('copy-to-clipboard', (_event, text) => {
clipboard.writeText(text);
return true;
});

5258
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "multi-hoster-uploader",
"version": "1.0.0",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {
"start": "electron .",
"dist": "electron-builder --win"
},
"dependencies": {
"undici": "^7.16.0"
},
"devDependencies": {
"electron": "^33.0.0",
"electron-builder": "^25.0.0"
},
"build": {
"appId": "com.multihoster.uploader",
"productName": "Multi Hoster Uploader",
"win": {
"target": "portable"
},
"files": [
"main.js",
"preload.js",
"lib/**/*",
"renderer/**/*"
]
}
}

33
preload.js Normal file
View File

@ -0,0 +1,33 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
// Config
getConfig: () => ipcRenderer.invoke('get-config'),
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
getHistory: () => ipcRenderer.invoke('get-history'),
clearHistory: () => ipcRenderer.invoke('clear-history'),
// File selection
selectFiles: () => ipcRenderer.invoke('select-files'),
// Upload control
startUpload: (payload) => ipcRenderer.invoke('start-upload', payload),
cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload),
// Clipboard
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
// Events (main -> renderer)
onUploadProgress: (callback) => {
ipcRenderer.on('upload-progress', (_event, data) => callback(data));
},
onUploadBatchDone: (callback) => {
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
},
removeAllListeners: () => {
ipcRenderer.removeAllListeners('upload-progress');
ipcRenderer.removeAllListeners('upload-batch-done');
}
});

1006
renderer/app.js Normal file

File diff suppressed because it is too large Load Diff

87
renderer/index.html Normal file
View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';">
<title>Multi Hoster Uploader</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav class="tab-bar">
<button class="tab active" data-view="upload">Upload</button>
<button class="tab" data-view="settings">Einstellungen</button>
<button class="tab" data-view="history">Verlauf</button>
</nav>
<!-- Upload View -->
<div id="upload-view" class="view active">
<div class="hoster-select" id="hosterSelect"></div>
<div class="health-check-panel">
<div class="health-check-actions">
<button class="btn btn-secondary" id="runHealthCheckBtn">Hoster Check</button>
<span class="health-check-status" id="healthCheckStatus"></span>
<label class="auto-health-check" title="Fuehrt vor dem Upload automatisch einen Hoster-Check aus">
<input type="checkbox" id="autoHealthCheckToggle" checked>
<span>Auto-Check vor Upload</span>
</label>
</div>
<div class="health-check-results" id="healthCheckResults"></div>
</div>
<div class="drop-zone" id="dropZone">
<div class="drop-icon">&#128193;</div>
<p>Dateien hierher ziehen oder klicken</p>
<button class="btn btn-primary" id="pickFilesBtn">Dateien waehlen</button>
</div>
<div class="file-list" id="fileList"></div>
<div class="upload-actions" id="uploadActions" style="display:none">
<button class="btn btn-primary" id="startUploadBtn">Upload starten</button>
<button class="btn btn-secondary" id="clearFilesBtn">Liste leeren</button>
</div>
<div class="upload-actions" id="cancelActions" style="display:none">
<button class="btn btn-danger" id="cancelUploadBtn">Abbrechen</button>
</div>
<div class="progress-section" id="progressSection" style="display:none"></div>
<div class="results-section" id="resultsSection" style="display:none">
<div class="results-header">
<h2 id="resultsTitle">Ergebnisse</h2>
<div class="results-buttons">
<button class="btn btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
<button class="btn btn-secondary" id="newUploadBtn">Neuer Upload</button>
</div>
</div>
<div id="resultsContainer"></div>
</div>
</div>
<!-- Settings View -->
<div id="settings-view" class="view">
<div class="settings-container">
<h2>API Keys</h2>
<p class="settings-hint">API-Keys findest du in den Einstellungen der jeweiligen Hoster-Webseite.</p>
<div class="settings-grid" id="settingsGrid"></div>
<button class="btn btn-primary" id="saveSettingsBtn">Speichern</button>
<span class="save-feedback" id="saveFeedback"></span>
</div>
</div>
<!-- History View -->
<div id="history-view" class="view">
<div class="history-container">
<div class="history-header">
<h2>Upload Verlauf</h2>
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf loeschen</button>
</div>
<div id="historyContainer"></div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

791
renderer/styles.css Normal file
View File

@ -0,0 +1,791 @@
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-card: #1e1e2e;
--bg-card-hover: #2a2a3e;
--bg-input: #2a2a3e;
--border: rgba(255, 255, 255, 0.1);
--border-hover: rgba(255, 255, 255, 0.2);
--text: #fff;
--text-muted: #888;
--text-dim: #666;
--accent: #667eea;
--accent-end: #764ba2;
--success: #00b894;
--success-end: #00cec9;
--danger: #e74c3c;
--warning: #fdcb6e;
--link-color: #00cec9;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 50%, #16213e 100%);
min-height: 100vh;
color: var(--text);
user-select: none;
}
/* Tab Bar */
.tab-bar {
display: flex;
gap: 2px;
padding: 12px 20px 0;
border-bottom: 1px solid var(--border);
background: rgba(0, 0, 0, 0.2);
}
.tab {
padding: 10px 24px;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Views */
.view {
display: none;
padding: 24px 28px;
max-width: 960px;
margin: 0 auto;
}
.view.active {
display: block;
}
/* Hoster Select */
.hoster-select {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.hoster-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
}
.hoster-chip:hover {
border-color: var(--border-hover);
background: var(--bg-card-hover);
}
.hoster-chip.selected {
border-color: var(--accent);
background: rgba(102, 126, 234, 0.15);
}
.hoster-chip input[type="checkbox"] {
accent-color: var(--accent);
width: 16px;
height: 16px;
}
.hoster-chip .hoster-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
}
.hoster-chip.selected .hoster-dot {
background: var(--success);
}
.hoster-chip.no-key {
opacity: 0.5;
}
.hoster-chip.no-key::after {
content: '(kein Key)';
font-size: 11px;
color: var(--warning);
}
/* Health Check */
.health-check-panel {
margin-bottom: 18px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
border-radius: 10px;
}
.health-check-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.health-check-status {
font-size: 12px;
color: var(--text-muted);
}
.auto-health-check {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
}
.auto-health-check input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--accent);
cursor: pointer;
}
.health-check-results {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.health-check-badge {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--border);
font-size: 12px;
line-height: 1.3;
color: var(--text);
background: rgba(255, 255, 255, 0.02);
}
.health-check-badge .health-check-hoster {
font-weight: 600;
}
.health-check-badge .health-check-msg {
color: var(--text-muted);
}
.health-check-badge.ok {
border-color: rgba(0, 184, 148, 0.5);
background: rgba(0, 184, 148, 0.12);
}
.health-check-badge.warn {
border-color: rgba(253, 203, 110, 0.5);
background: rgba(253, 203, 110, 0.12);
}
.health-check-badge.error {
border-color: rgba(231, 76, 60, 0.5);
background: rgba(231, 76, 60, 0.12);
}
.health-check-badge.skipped {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
}
/* Drop Zone */
.drop-zone {
border: 2px dashed rgba(255, 255, 255, 0.12);
border-radius: 16px;
padding: 50px 40px;
text-align: center;
transition: all 0.2s;
cursor: pointer;
margin-bottom: 20px;
}
.drop-zone:hover {
border-color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.02);
}
.drop-zone.drag-over {
border-color: var(--accent);
background: rgba(102, 126, 234, 0.08);
}
.drop-zone.hidden {
display: none;
}
.drop-icon {
font-size: 40px;
margin-bottom: 12px;
opacity: 0.6;
}
.drop-zone p {
color: var(--text-muted);
margin-bottom: 16px;
font-size: 14px;
}
/* Buttons */
.btn {
padding: 10px 22px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-primary {
background: linear-gradient(90deg, var(--accent), var(--accent-end));
color: #fff;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.08);
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.12);
color: var(--text);
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover {
opacity: 0.9;
}
.btn-sm {
padding: 5px 12px;
font-size: 12px;
border-radius: 6px;
}
/* File List */
.file-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
font-size: 13px;
}
.file-item .file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-item .file-size {
color: var(--text-muted);
margin: 0 16px;
font-size: 12px;
flex-shrink: 0;
}
.file-item .remove-btn {
background: none;
border: none;
color: var(--text-dim);
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.file-item .remove-btn:hover {
color: var(--danger);
}
/* Upload Actions */
.upload-actions {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
/* Progress Section */
.progress-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.progress-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
}
.progress-card .file-title {
font-weight: 600;
margin-bottom: 12px;
font-size: 14px;
}
.progress-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.progress-row:last-child {
margin-bottom: 0;
}
.progress-hoster {
width: 130px;
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
}
.progress-track {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-end));
border-radius: 4px;
transition: width 0.15s ease;
width: 0%;
}
.progress-fill.done {
background: linear-gradient(90deg, var(--success), var(--success-end));
}
.progress-fill.error {
background: var(--danger);
}
.progress-percent {
width: 42px;
text-align: right;
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
}
.progress-status {
width: 100px;
font-size: 11px;
color: var(--text-dim);
flex-shrink: 0;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-status.done {
color: var(--success);
}
.progress-status.error {
color: var(--danger);
}
/* Results Section - Table like z-o-o-m */
.results-section {
margin-top: 8px;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.results-header h2 {
font-size: 18px;
}
.results-buttons {
display: flex;
gap: 8px;
}
.results-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.results-table thead th {
background: rgba(0, 0, 0, 0.3);
padding: 8px 12px;
text-align: left;
font-weight: 500;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
user-select: none;
}
.results-table thead th.sortable {
cursor: pointer;
}
.results-table thead th.sortable .sort-indicator {
margin-left: 6px;
font-size: 10px;
opacity: 0.55;
}
.results-table thead th.sortable.active {
color: var(--accent);
}
.results-table thead th.sortable.active .sort-indicator {
opacity: 1;
}
.results-table tbody tr {
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.results-table tbody tr:last-child {
border-bottom: none;
}
.results-table tbody tr:hover {
background: rgba(255, 255, 255, 0.04);
}
.results-table tbody tr.selected {
background: rgba(102, 126, 234, 0.15);
}
.results-table tbody tr.selected:hover {
background: rgba(102, 126, 234, 0.2);
}
.results-table tbody tr.error {
opacity: 0.6;
}
.results-table tbody tr.error .col-link {
color: var(--danger);
}
.results-table td {
padding: 7px 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.col-date {
width: 140px;
color: var(--text-muted);
}
.col-filename {
max-width: 300px;
color: var(--text);
}
.col-host {
width: 120px;
color: var(--text-muted);
}
.col-link {
color: var(--link-color);
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 11px;
}
/* Copy button (used in history) */
.copy-btn {
padding: 4px 10px;
background: rgba(102, 126, 234, 0.2);
color: var(--accent);
border: none;
border-radius: 6px;
cursor: pointer;
flex-shrink: 0;
font-size: 11px;
transition: all 0.15s;
}
.copy-btn:hover {
background: rgba(102, 126, 234, 0.35);
}
/* Copy toast */
.copy-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: rgba(0, 184, 148, 0.9);
color: #fff;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
z-index: 999;
}
.copy-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* Settings */
.settings-container {
max-width: 600px;
}
.settings-container h2 {
font-size: 18px;
margin-bottom: 6px;
}
.settings-hint {
color: var(--text-muted);
font-size: 13px;
margin-bottom: 20px;
}
.settings-grid {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 20px;
}
.settings-row {
display: flex;
align-items: center;
gap: 12px;
}
.settings-row .hoster-label {
width: 140px;
font-size: 13px;
font-weight: 500;
flex-shrink: 0;
}
.settings-row .key-input {
flex: 1;
padding: 10px 14px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
font-family: 'Cascadia Code', 'Consolas', monospace;
outline: none;
transition: border-color 0.2s;
}
.settings-row .key-input:focus {
border-color: var(--accent);
}
.settings-row .key-input::placeholder {
color: var(--text-dim);
}
.toggle-vis {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 16px;
padding: 4px;
}
.toggle-vis:hover {
color: var(--text);
}
.settings-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.save-feedback {
margin-left: 12px;
font-size: 13px;
color: var(--success);
transition: opacity 0.3s;
}
/* History */
.history-container h2 {
font-size: 18px;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.empty-state {
color: var(--text-muted);
text-align: center;
padding: 40px;
font-size: 14px;
}
.history-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.history-meta {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 12px;
color: var(--text-muted);
}
.history-file {
margin-bottom: 10px;
}
.history-file:last-child {
margin-bottom: 0;
}
.history-file-name {
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
}
.history-result {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
margin-bottom: 3px;
font-size: 12px;
}
.history-hoster {
width: 110px;
color: var(--text-muted);
flex-shrink: 0;
}
.history-url {
color: var(--link-color);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 11px;
}
.history-result.error .history-url {
color: var(--danger);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
requests>=2.32.0