chore: initial commit - Electron multi-hoster uploader
This commit is contained in:
commit
9729ec6f3e
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
release/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
44
README.md
Normal file
44
README.md
Normal 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
244
app.py
Normal 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
240
electron-config.json
Normal 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
208
hosters.py
Normal 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
70
lib/config-store.js
Normal 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
375
lib/hosters.js
Normal 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
149
lib/upload-manager.js
Normal 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
517
lib/vidmoly-upload.js
Normal 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
289
main.js
Normal 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
5258
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal 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
33
preload.js
Normal 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
1006
renderer/app.js
Normal file
File diff suppressed because it is too large
Load Diff
87
renderer/index.html
Normal file
87
renderer/index.html
Normal 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">📁</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
791
renderer/styles.css
Normal 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
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
requests>=2.32.0
|
||||||
Loading…
Reference in New Issue
Block a user