add android app

This commit is contained in:
2026-08-12 22:09:29 +02:00
parent 43e95c9d3e
commit 7e723ae821
30 changed files with 1872 additions and 45 deletions

370
gui.py
View File

@@ -14,6 +14,7 @@ import shutil
import socket
import ssl
import threading
import signal
from http.server import HTTPServer, BaseHTTPRequestHandler
from PySide6.QtCore import Qt, QThread, QObject, Signal, QUrl, QFile, QIODevice, QTimer, QMimeData, QProcess
@@ -37,6 +38,25 @@ def normalize_file_path(path_str: str) -> str:
path_str = urllib.parse.unquote(path_str[7:])
return os.path.abspath(path_str)
def normalize_api_url(url: str) -> str:
if not url:
return ""
url = url.strip().strip("'\"")
if not url:
return ""
if not (url.startswith("http://") or url.startswith("https://")):
url = "http://" + url
parsed = urllib.parse.urlparse(url)
path = parsed.path.rstrip('/')
if not path or path == "":
return f"{url.rstrip('/')}/api/v2/upload"
elif path.endswith("/api/v2"):
return f"{url.rstrip('/')}/upload"
elif not path.endswith("/upload"):
return f"{url.rstrip('/')}/api/v2/upload"
return url
def parse_upload_target(arg: str) -> tuple[str, bool]:
if not arg:
return ("", False)
@@ -206,6 +226,14 @@ def add_history_entry(entry):
save_history(history)
DEFAULT_CONFIG = {
"instances": [
{
"name": "Default Instance",
"api_url": "",
"api_key": ""
}
],
"active_instance_index": 0,
"api_url": "",
"api_key": "",
"default_rating": "",
@@ -227,6 +255,27 @@ def load_config():
data = json.load(f)
config = DEFAULT_CONFIG.copy()
config.update(data)
instances = config.get("instances")
if not isinstance(instances, list) or not instances:
url = data.get("api_url") or config.get("api_url", "")
key = data.get("api_key") or config.get("api_key", "")
instances = [{"name": "Default Instance", "api_url": normalize_api_url(url), "api_key": key}]
config["instances"] = instances
config["active_instance_index"] = 0
else:
for inst in instances:
if "api_url" in inst:
inst["api_url"] = normalize_api_url(inst["api_url"])
idx = config.get("active_instance_index", 0)
if not isinstance(idx, int) or idx < 0 or idx >= len(config["instances"]):
idx = 0
config["active_instance_index"] = 0
active = config["instances"][idx]
config["api_url"] = normalize_api_url(active.get("api_url", ""))
config["api_key"] = active.get("api_key", "")
return config
except Exception:
return DEFAULT_CONFIG.copy()
@@ -241,6 +290,8 @@ def get_env_config():
os.path.join(os.getcwd(), ".env")
]
dotenv_path = None
env_url = ""
env_key = ""
for path in candidates:
if os.path.exists(path):
dotenv_path = path
@@ -256,9 +307,11 @@ def get_env_config():
key = k.strip()
val = v.strip().strip("'\"")
if key == "F0CKM_URL" and val:
url = val if val.endswith("/api/v2/upload") else f"{val.rstrip('/')}/api/v2/upload"
url = normalize_api_url(val)
env_url = url
config["api_url"] = url
elif key == "API_KEY" and val:
env_key = val
config["api_key"] = val
elif key == "RATING":
config["default_rating"] = val
@@ -277,11 +330,37 @@ def get_env_config():
for k, v in user_cfg.items():
config[k] = v
instances = config.get("instances")
if not isinstance(instances, list) or not instances:
url = config.get("api_url") or env_url
key = config.get("api_key") or env_key
instances = [{"name": "Default Instance", "api_url": normalize_api_url(url), "api_key": key}]
config["instances"] = instances
config["active_instance_index"] = 0
idx = config.get("active_instance_index", 0)
if not isinstance(idx, int) or idx < 0 or idx >= len(instances):
idx = 0
config["active_instance_index"] = 0
active_inst = instances[idx]
config["api_url"] = normalize_api_url(active_inst.get("api_url", ""))
config["api_key"] = active_inst.get("api_key", "")
config["active_instance_name"] = active_inst.get("name", "Default Instance")
return config
def save_config(config):
try:
os.makedirs(CONFIG_DIR, exist_ok=True)
if "instances" in config and isinstance(config["instances"], list) and config["instances"]:
for inst in config["instances"]:
if "api_url" in inst:
inst["api_url"] = normalize_api_url(inst["api_url"])
idx = config.get("active_instance_index", 0)
if 0 <= idx < len(config["instances"]):
config["api_url"] = normalize_api_url(config["instances"][idx].get("api_url", ""))
config["api_key"] = config["instances"][idx].get("api_key", "")
with open(CONFIG_PATH, 'w') as f:
json.dump(config, f, indent=4)
return True
@@ -334,7 +413,7 @@ class ConnectionTester(QObject):
def __init__(self, url, api_key):
super().__init__()
self.url = url
self.url = normalize_api_url(url)
self.api_key = api_key
def run(self):
@@ -389,7 +468,7 @@ class SettingsDialog(QDialog):
super().__init__(parent)
self.tray_app = tray_app
self.setWindowTitle("f0ckm Uploader Settings")
self.resize(540, 680)
self.resize(560, 700)
app_icon = get_app_icon()
if not app_icon.isNull():
@@ -397,6 +476,10 @@ class SettingsDialog(QDialog):
self.tester_thread = None
self.tester = None
self.instances = []
self.current_instance_index = 0
self._ignore_instance_signals = False
self.init_ui()
@@ -414,7 +497,7 @@ class SettingsDialog(QDialog):
header_text_layout = QVBoxLayout()
header_title = QLabel("f0ckm Uploader")
header_title.setStyleSheet("font-size: 16px; font-weight: bold;")
header_subtitle = QLabel("Configure connection settings and default options")
header_subtitle = QLabel("Configure f0ckm instances, connection settings and default options")
header_subtitle.setStyleSheet("font-size: 11px; opacity: 0.7;")
header_text_layout.addWidget(header_title)
header_text_layout.addWidget(header_subtitle)
@@ -424,20 +507,45 @@ class SettingsDialog(QDialog):
header_layout.addStretch()
main_layout.addLayout(header_layout)
# Section 1: API Connection Settings
grp_api = QGroupBox("API Connection")
# Section 1: API Connection & Instances Settings
grp_api = QGroupBox("f0ckm Instances & Connection")
api_layout = QFormLayout(grp_api)
api_layout.setContentsMargins(15, 20, 15, 15)
api_layout.setSpacing(10)
# Instance Selection & Management Row
inst_row_layout = QHBoxLayout()
self.cb_instance = QComboBox()
self.cb_instance.currentIndexChanged.connect(self.on_instance_changed)
inst_row_layout.addWidget(self.cb_instance, stretch=1)
self.btn_add_instance = QPushButton("+ Add")
self.btn_add_instance.setToolTip("Add a new f0ckm instance")
self.btn_add_instance.clicked.connect(self.on_add_instance)
inst_row_layout.addWidget(self.btn_add_instance)
self.btn_rename_instance = QPushButton("Rename")
self.btn_rename_instance.setToolTip("Rename currently selected instance")
self.btn_rename_instance.clicked.connect(self.on_rename_instance)
inst_row_layout.addWidget(self.btn_rename_instance)
self.btn_delete_instance = QPushButton("Delete")
self.btn_delete_instance.setToolTip("Delete currently selected instance")
self.btn_delete_instance.clicked.connect(self.on_delete_instance)
inst_row_layout.addWidget(self.btn_delete_instance)
api_layout.addRow("Instance:", inst_row_layout)
self.txt_url = QLineEdit()
self.txt_url.setPlaceholderText("https://example.com/api/v2/upload")
self.txt_url.textChanged.connect(self.on_url_or_key_edited)
api_layout.addRow("API URL:", self.txt_url)
key_layout = QHBoxLayout()
self.txt_key = QLineEdit()
self.txt_key.setEchoMode(QLineEdit.Password)
self.txt_key.setPlaceholderText("your_api_key_here")
self.txt_key.textChanged.connect(self.on_url_or_key_edited)
key_layout.addWidget(self.txt_key)
self.btn_toggle_key = QPushButton("Show")
@@ -578,11 +686,99 @@ class SettingsDialog(QDialog):
else:
self.txt_key.setEchoMode(QLineEdit.Password)
self.btn_toggle_key.setText("Show")
def refresh_instance_combo(self):
self._ignore_instance_signals = True
self.cb_instance.clear()
for inst in self.instances:
self.cb_instance.addItem(inst.get("name", "Unnamed Instance"))
if 0 <= self.current_instance_index < len(self.instances):
self.cb_instance.setCurrentIndex(self.current_instance_index)
self._ignore_instance_signals = False
self.load_instance_fields(self.current_instance_index)
def load_instance_fields(self, index):
if 0 <= index < len(self.instances):
inst = self.instances[index]
self.txt_url.setText(inst.get("api_url", ""))
self.txt_key.setText(inst.get("api_key", ""))
def on_instance_changed(self, new_index):
if self._ignore_instance_signals or new_index < 0 or new_index >= len(self.instances):
return
if 0 <= self.current_instance_index < len(self.instances):
self.instances[self.current_instance_index]["api_url"] = self.txt_url.text().strip()
self.instances[self.current_instance_index]["api_key"] = self.txt_key.text().strip()
self.current_instance_index = new_index
self.load_instance_fields(new_index)
def on_url_or_key_edited(self):
if self._ignore_instance_signals:
return
if 0 <= self.current_instance_index < len(self.instances):
self.instances[self.current_instance_index]["api_url"] = self.txt_url.text().strip()
self.instances[self.current_instance_index]["api_key"] = self.txt_key.text().strip()
def on_add_instance(self):
name, ok = QInputDialog.getText(self, "Add Instance", "Enter name for new f0ckm instance:")
if ok and name.strip():
name = name.strip()
self.on_url_or_key_edited()
new_inst = {
"name": name,
"api_url": "",
"api_key": ""
}
self.instances.append(new_inst)
self.current_instance_index = len(self.instances) - 1
self.refresh_instance_combo()
def on_rename_instance(self):
if not (0 <= self.current_instance_index < len(self.instances)):
return
curr_name = self.instances[self.current_instance_index].get("name", "")
name, ok = QInputDialog.getText(self, "Rename Instance", "Enter new instance name:", text=curr_name)
if ok and name.strip():
name = name.strip()
self.instances[self.current_instance_index]["name"] = name
self.refresh_instance_combo()
def on_delete_instance(self):
if len(self.instances) <= 1:
QMessageBox.information(self, "Cannot Delete", "You must keep at least one f0ckm instance.")
return
curr_name = self.instances[self.current_instance_index].get("name", "")
reply = QMessageBox.question(
self,
"Delete Instance",
f"Are you sure you want to delete instance '{curr_name}'?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.instances.pop(self.current_instance_index)
self.current_instance_index = max(0, self.current_instance_index - 1)
self.refresh_instance_combo()
def load_current_settings(self):
config = load_config()
self.txt_url.setText(config.get("api_url", ""))
self.txt_key.setText(config.get("api_key", ""))
config = get_env_config()
self.instances = [dict(inst) for inst in config.get("instances", [])]
if not self.instances:
self.instances = [{
"name": "Default Instance",
"api_url": config.get("api_url", ""),
"api_key": config.get("api_key", "")
}]
self.current_instance_index = config.get("active_instance_index", 0)
if self.current_instance_index < 0 or self.current_instance_index >= len(self.instances):
self.current_instance_index = 0
self.refresh_instance_combo()
rating = config.get("default_rating", "").lower()
rating_map = {"": 0, "s": 1, "sfw": 1, "safe": 1, "q": 2, "nsfw": 2, "questionable": 2, "e": 3, "nsfl": 3, "explicit": 3}
@@ -620,7 +816,10 @@ class SettingsDialog(QDialog):
self.btn_test.setEnabled(False)
self.btn_test.setText("Testing...")
url = self.txt_url.text().strip()
raw_url = self.txt_url.text().strip()
url = normalize_api_url(raw_url)
if url != raw_url:
self.txt_url.setText(url)
key = self.txt_key.text().strip()
self.tester_thread = QThread()
@@ -671,9 +870,19 @@ class SettingsDialog(QDialog):
play_success_sound(sound_path, volume=self.slider_volume.value())
def save_settings(self):
self.on_url_or_key_edited()
active_idx = self.cb_instance.currentIndex()
if active_idx < 0 or active_idx >= len(self.instances):
active_idx = 0
active_inst = self.instances[active_idx] if self.instances else {"api_url": "", "api_key": ""}
config = {
"api_url": self.txt_url.text().strip(),
"api_key": self.txt_key.text().strip(),
"instances": self.instances,
"active_instance_index": active_idx,
"api_url": active_inst.get("api_url", ""),
"api_key": active_inst.get("api_key", ""),
"default_rating": ["", "s", "q", "e"][self.cb_rating.currentIndex()],
"default_visibility": ["0", "1", "2"][self.cb_visibility.currentIndex()],
"default_tags": self.txt_tags.text().strip(),
@@ -687,11 +896,14 @@ class SettingsDialog(QDialog):
"success_audio_path": self.txt_sound_path.text().strip(),
"success_audio_volume": self.slider_volume.value()
}
if save_config(config):
set_autostart(config["autostart"])
if self.tray_app and hasattr(self.tray_app, "load_tray_icon"):
self.tray_app.load_tray_icon()
if self.tray_app:
if hasattr(self.tray_app, "load_tray_icon"):
self.tray_app.load_tray_icon()
if hasattr(self.tray_app, "rebuild_instance_menu"):
self.tray_app.rebuild_instance_menu()
self.accept()
else:
QMessageBox.critical(self, "Error", "Failed to save configuration file.")
@@ -1203,7 +1415,7 @@ class URLPostThread(QThread):
},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as resp:
with urllib.request.urlopen(req, timeout=300) as resp:
resp_text = resp.read().decode("utf-8")
print(f"[f0ckm-gui] [URL POST OK] Server Response: {resp_text}", flush=True)
self.finished.emit(True, resp_text)
@@ -1232,7 +1444,7 @@ class StreamingDownloadThread(QThread):
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
)
with urllib.request.urlopen(req, timeout=30) as resp:
with urllib.request.urlopen(req, timeout=120) as resp:
total_size = int(resp.headers.get("Content-Length", 0))
content_type = resp.headers.get("Content-Type", "")
print(f"[f0ckm-gui] [DOWNLOAD HTTP OK] Content-Type: '{content_type}', Length: {total_size} bytes", flush=True)
@@ -1253,7 +1465,8 @@ class StreamingDownloadThread(QThread):
temp_path = os.path.join(temp_dir, f"f0ckm_remote_{int(time.time())}_{clean_name}")
received = 0
chunk_size = 32768
chunk_size = 131072
last_emit = 0.0
with open(temp_path, "wb") as f:
while True:
if self.aborted:
@@ -1265,7 +1478,10 @@ class StreamingDownloadThread(QThread):
break
f.write(chunk)
received += len(chunk)
self.progress.emit(received, total_size)
now = time.time()
if now - last_emit >= 0.05 or (total_size > 0 and received == total_size):
self.progress.emit(received, total_size)
last_emit = now
print(f"[f0ckm-gui] [DOWNLOAD COMPLETE] Saved remote file to: {temp_path} ({received} bytes)", flush=True)
self.finished.emit(True, temp_path)
@@ -1320,6 +1536,8 @@ class StreamingUploadThread(QThread):
path += "?" + parsed_url.query
raw_sock = socket.create_connection((host, port), timeout=30)
raw_sock.settimeout(1800.0)
if parsed_url.scheme == "https":
ctx = ssl.create_default_context()
self.sock = ctx.wrap_socket(raw_sock, server_hostname=host)
@@ -1343,7 +1561,9 @@ class StreamingUploadThread(QThread):
total_sent += len(encoder.header_bytes)
self.progress.emit(total_sent, encoder.total_size)
chunk_size = 32768
chunk_size = 131072
last_progress_time = 0.0
with open(self.file_path, "rb") as f:
while True:
if self.aborted:
@@ -1356,10 +1576,12 @@ class StreamingUploadThread(QThread):
self.sock.sendall(chunk)
total_sent += len(chunk)
pct = min(99, int((total_sent / encoder.total_size) * 100))
bytes_sent_calc = int(encoder.total_size * (pct / 100.0))
self.progress.emit(bytes_sent_calc, encoder.total_size)
time.sleep(0.005)
now = time.time()
if now - last_progress_time >= 0.05 or total_sent >= encoder.total_size:
pct = min(99, int((total_sent / encoder.total_size) * 100))
bytes_sent_calc = int(encoder.total_size * (pct / 100.0))
self.progress.emit(bytes_sent_calc, encoder.total_size)
last_progress_time = now
self.sock.sendall(encoder.footer_bytes)
total_sent += len(encoder.footer_bytes)
@@ -1423,7 +1645,10 @@ class StreamingUploadThread(QThread):
if self.aborted:
self.finished.emit(False, "ABORTED")
else:
self.finished.emit(False, str(e))
err_msg = str(e)
if isinstance(e, (socket.timeout, TimeoutError)) or "timed out" in err_msg.lower():
err_msg = f"Upload timed out: socket read timed out ({err_msg})"
self.finished.emit(False, err_msg)
finally:
if self.sock:
try:
@@ -1449,6 +1674,7 @@ class SystemTrayApp(QObject):
self.upload_queue = [] # List of tuples: (target_str, is_url)
self.is_uploading = False
self.active_thread = None
self._running_threads = set()
self.current_target = None
self.total_queue_count = 0
self.current_item_index = 0
@@ -1459,6 +1685,11 @@ class SystemTrayApp(QObject):
title_action = QAction("f0ckm Uploader", self.menu)
title_action.setEnabled(False)
self.menu.addAction(title_action)
# Instance Submenu
self.instance_menu = QMenu("Switch Instance", self.menu)
self.menu.addMenu(self.instance_menu)
self.menu.addSeparator()
# Actions
@@ -1505,6 +1736,59 @@ class SystemTrayApp(QObject):
self.settings_dialog = None
self.gallery_window = None
self.rebuild_instance_menu()
def _start_thread(self, thread):
self._running_threads.add(thread)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(lambda: self._running_threads.discard(thread))
self.active_thread = thread
thread.start()
def rebuild_instance_menu(self):
self.instance_menu.clear()
config = get_env_config()
instances = config.get("instances", [])
active_idx = config.get("active_instance_index", 0)
active_name = config.get("active_instance_name", "Default Instance")
self.instance_menu.setTitle(f"Instance: {active_name}")
for idx, inst in enumerate(instances):
name = inst.get("name", f"Instance {idx + 1}")
url = inst.get("api_url", "")
action = QAction(name, self.instance_menu)
if url:
action.setToolTip(url)
action.setCheckable(True)
if idx == active_idx:
action.setChecked(True)
action.triggered.connect(lambda checked=False, i=idx: self.switch_active_instance(i))
self.instance_menu.addAction(action)
self.instance_menu.addSeparator()
manage_action = QAction("Manage Instances...", self.instance_menu)
manage_action.triggered.connect(self.on_open_settings)
self.instance_menu.addAction(manage_action)
def switch_active_instance(self, index):
config = get_env_config()
instances = config.get("instances", [])
if 0 <= index < len(instances):
config["active_instance_index"] = index
active_inst = instances[index]
config["api_url"] = active_inst.get("api_url", "")
config["api_key"] = active_inst.get("api_key", "")
save_config(config)
name = active_inst.get("name", "Instance")
print(f"[f0ckm-gui] Switched active instance to '{name}' ({active_inst.get('api_url', '')})", flush=True)
self.show_message("f0ckm Instance Switched", f"Active instance changed to: {name}", QSystemTrayIcon.Information)
self.rebuild_instance_menu()
if self.settings_dialog and self.settings_dialog.isVisible():
self.settings_dialog.load_current_settings()
def enqueue_targets(self, targets):
if isinstance(targets, str):
targets = [targets]
@@ -1554,12 +1838,7 @@ class SystemTrayApp(QObject):
self.upload_file_direct(target_str)
def _on_single_task_complete(self):
if self.active_thread:
try:
self.active_thread.deleteLater()
except Exception:
pass
self.active_thread = None
self.active_thread = None
QTimer.singleShot(50, self.process_next_in_queue)
def on_notification_clicked(self):
@@ -1673,9 +1952,9 @@ class SystemTrayApp(QObject):
rem_str = f"\n({len(self.upload_queue)} waiting in queue)" if self.upload_queue else ""
self.tray.setToolTip(f"Uploading URL {batch_prefix}to f0ckm...\n{url[:45]}{rem_str}")
self.active_thread = URLPostThread(api_url, api_key, url, rating, tags, visibility, is_oc)
self.active_thread.finished.connect(lambda ok, res: self.on_url_post_finished(ok, res, url))
self.active_thread.start()
thread = URLPostThread(api_url, api_key, url, rating, tags, visibility, is_oc)
thread.finished.connect(lambda ok, res: self.on_url_post_finished(ok, res, url))
self._start_thread(thread)
def on_url_post_finished(self, ok, result, url):
if ok:
@@ -1710,10 +1989,10 @@ class SystemTrayApp(QObject):
print(f"[f0ckm-gui] JSON parse notice: {e}", flush=True)
print(f"[f0ckm-gui] Direct URL API notice: Falling back to local download of '{url}'", flush=True)
self.active_thread = StreamingDownloadThread(url)
self.active_thread.progress.connect(self.on_download_progress)
self.active_thread.finished.connect(lambda ok_dl, res_dl: self.on_download_finished(ok_dl, res_dl, url))
self.active_thread.start()
thread = StreamingDownloadThread(url)
thread.progress.connect(self.on_download_progress)
thread.finished.connect(lambda ok_dl, res_dl: self.on_download_finished(ok_dl, res_dl, url))
self._start_thread(thread)
def on_download_progress(self, bytes_received, bytes_total):
batch_prefix = f"[{self.current_item_index}/{self.total_queue_count}] " if self.total_queue_count > 1 else ""
@@ -1775,10 +2054,10 @@ class SystemTrayApp(QObject):
rem_str = f"\n({len(self.upload_queue)} waiting in queue)" if self.upload_queue else ""
self.tray.setToolTip(f"Uploading {batch_prefix}{filename}...\n0% (0 B){rem_str}")
self.active_thread = StreamingUploadThread(api_url, api_key, file_path, rating, tags, visibility, is_oc)
self.active_thread.progress.connect(self.on_upload_progress)
self.active_thread.finished.connect(lambda ok, resp: self.on_upload_finished(ok, resp, file_path))
self.active_thread.start()
thread = StreamingUploadThread(api_url, api_key, file_path, rating, tags, visibility, is_oc)
thread.progress.connect(self.on_upload_progress)
thread.finished.connect(lambda ok, resp: self.on_upload_finished(ok, resp, file_path))
self._start_thread(thread)
def on_upload_progress(self, bytes_sent, bytes_total):
if bytes_total > 0:
@@ -2079,8 +2358,11 @@ def main():
else:
if not cfg.get("api_url") or not cfg.get("api_key"):
print("[f0ckm-gui] First launch detected (missing API credentials). Opening settings dialog...", flush=True)
QTimer.singleShot(400, tray_app.on_open_settings)
signal.signal(signal.SIGINT, signal.SIG_DFL)
sig_timer = QTimer()
sig_timer.timeout.connect(lambda: None)
sig_timer.start(500)
sys.exit(app.exec())
if __name__ == "__main__":