diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..c8b7477 --- /dev/null +++ b/gui.py @@ -0,0 +1,961 @@ +#!/usr/bin/env python3 +import os +import sys +import json +import subprocess +import tempfile +import urllib.request +import urllib.error +import time +import mimetypes +import urllib.parse + +def normalize_file_path(path_str: str) -> str: + if not path_str: + return "" + path_str = path_str.strip().strip("'\"") + if path_str.startswith("file://"): + url = QUrl(path_str) + if url.isLocalFile(): + path_str = url.toLocalFile() + else: + path_str = urllib.parse.unquote(path_str[7:]) + return os.path.abspath(path_str) +from PySide6.QtCore import Qt, QThread, QObject, Signal, QUrl, QFile, QIODevice +from PySide6.QtGui import QIcon, QAction, QPixmap, QPainter, QColor, QFont, QPen, QImage +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QHttpMultiPart, QHttpPart, QNetworkReply, QLocalServer, QLocalSocket +from PySide6.QtWidgets import ( + QApplication, QSystemTrayIcon, QMenu, QDialog, QVBoxLayout, QHBoxLayout, + QLabel, QLineEdit, QComboBox, QCheckBox, QPushButton, QGroupBox, + QFormLayout, QMessageBox, QFileDialog +) + +# Configuration paths +CONFIG_DIR = os.path.expanduser("~/.config/f0ckm-uploader") +CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json") + +DEFAULT_CONFIG = { + "api_url": "", + "api_key": "", + "default_rating": "", + "default_tags": "", + "default_is_oc": False, + "enable_notifications": True, + "show_progress_dialog": True, + "autostart": False, + "icon_theme": "dark" +} + +def load_config(): + if not os.path.exists(CONFIG_PATH): + return DEFAULT_CONFIG.copy() + try: + with open(CONFIG_PATH, 'r') as f: + data = json.load(f) + config = DEFAULT_CONFIG.copy() + config.update(data) + return config + except Exception: + return DEFAULT_CONFIG.copy() + +def get_env_config(): + config = load_config() + script_dir = os.path.dirname(os.path.abspath(__file__)) + candidates = [ + os.path.join(script_dir, ".env"), + "/home/kibi/Projects/f0ckm-uploader/.env", + os.path.expanduser("~/.config/f0ckm-uploader/.env"), + os.path.join(os.getcwd(), ".env") + ] + dotenv_path = None + for path in candidates: + if os.path.exists(path): + dotenv_path = path + break + + if dotenv_path: + try: + with open(dotenv_path, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + 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" + config["api_url"] = url + elif key == "API_KEY" and val: + config["api_key"] = val + elif key == "RATING": + config["default_rating"] = val + elif key == "TAGS": + config["default_tags"] = val + elif key == "VISIBILITY": + config["default_visibility"] = val + elif key == "ICON_THEME": + config["icon_theme"] = val.lower() + except Exception as e: + print(f"Error reading .env from {dotenv_path}: {e}") + return config + +def save_config(config): + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + with open(CONFIG_PATH, 'w') as f: + json.dump(config, f, indent=4) + return True + except Exception as e: + print(f"Failed to save config: {e}") + return False + +def set_autostart(enabled): + autostart_dir = os.path.expanduser("~/.config/autostart") + autostart_path = os.path.join(autostart_dir, "kde-uploader-gui.desktop") + + if enabled: + try: + os.makedirs(autostart_dir, exist_ok=True) + # Use the installed path ~/.local/bin/kde-uploader-gui if possible + exec_path = os.path.expanduser("~/.local/bin/kde-uploader-gui") + if not os.path.exists(exec_path): + exec_path = os.path.abspath(sys.argv[0]) + + content = f"""[Desktop Entry] +Type=Application +Name=f0ckm Uploader GUI +Comment=System tray GUI and settings for f0ckm Uploader +Exec={exec_path} +Icon=cloud-upload-symbolic +Terminal=false +Categories=Utility;Network; +X-GNOME-Autostart-enabled=true +""" + with open(autostart_path, "w") as f: + f.write(content) + os.chmod(autostart_path, 0o755) + except Exception as e: + print(f"Failed to create autostart entry: {e}") + else: + if os.path.exists(autostart_path): + try: + os.remove(autostart_path) + except Exception as e: + print(f"Failed to remove autostart entry: {e}") + +# ========================================== +# Connection Tester Worker +# ========================================== +class ConnectionTester(QObject): + finished = Signal(bool, str) # Success, Message + + def __init__(self, url, api_key): + super().__init__() + self.url = url + self.api_key = api_key + + def run(self): + try: + # Send an empty POST request to test authorization and connection + req = urllib.request.Request( + self.url, + data=b"", + headers={ + "X-Api-Key": self.api_key, + "User-Agent": "f0ckm-Uploader-GUI/1.0" + }, + method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=5) as response: + self.finished.emit(True, "Connection successful! Server responded with HTTP 200.") + except urllib.error.HTTPError as e: + body = e.read().decode('utf-8', errors='ignore') + if e.code == 401: + self.finished.emit(False, "Unauthorized: Invalid API Key.") + elif e.code in (400, 422): + # Key is valid but payload is invalid (empty file parameter), which confirms authentication works + self.finished.emit(True, "Connection successful! (API key is valid)") + else: + self.finished.emit(False, f"HTTP Error {e.code}: {body or e.reason}") + except urllib.error.URLError as e: + self.finished.emit(False, f"Connection failed: {e.reason}") + except Exception as e: + self.finished.emit(False, f"Error: {str(e)}") + +# ========================================== +# Premium Style Sheet +# ========================================== +STYLE_SHEET = """ +QDialog { + background-color: #121216; + color: #fffffe; + font-family: "Segoe UI", "Inter", "Roboto", "Noto Sans", sans-serif; + font-size: 13px; +} +QLabel { + color: #e2e2e9; +} +QLabel#title { + font-size: 18px; + font-weight: bold; + color: #7f5af0; + margin-bottom: 5px; +} +QGroupBox { + background-color: #1a1a24; + border: 1px solid #2f2f3d; + border-radius: 8px; + margin-top: 15px; + padding-top: 15px; + font-weight: bold; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 15px; + padding: 0 5px; + background-color: #121216; + color: #7f5af0; +} +QLineEdit { + background-color: #242432; + border: 1px solid #2f2f3d; + border-radius: 6px; + padding: 6px 10px; + color: #fffffe; + selection-background-color: #7f5af0; +} +QLineEdit:focus { + border: 1px solid #7f5af0; +} +QComboBox { + background-color: #242432; + border: 1px solid #2f2f3d; + border-radius: 6px; + padding: 6px 10px; + color: #fffffe; +} +QComboBox:focus { + border: 1px solid #7f5af0; +} +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 25px; + border-left-width: 0px; +} +QComboBox QAbstractItemView { + background-color: #242432; + border: 1px solid #2f2f3d; + selection-background-color: #7f5af0; + selection-color: #fffffe; + color: #fffffe; +} +QCheckBox { + color: #e2e2e9; +} +QCheckBox:hover { + color: #ffffff; +} +QPushButton { + background-color: #242432; + border: 1px solid #2f2f3d; + border-radius: 6px; + padding: 8px 16px; + color: #fffffe; + font-weight: bold; +} +QPushButton:hover { + background-color: #2f2f42; + border: 1px solid #42425a; +} +QPushButton:pressed { + background-color: #1a1a24; +} +QPushButton#primary { + background-color: #7f5af0; + border: 1px solid #7f5af0; +} +QPushButton#primary:hover { + background-color: #9272f2; + border: 1px solid #9272f2; +} +QPushButton#primary:pressed { + background-color: #694ad4; +} +QPushButton#test { + background-color: #2cb67d; + border: 1px solid #2cb67d; + color: #fffffe; +} +QPushButton#test:hover { + background-color: #34cf8f; + border: 1px solid #34cf8f; +} +QPushButton#test:pressed { + background-color: #249a68; +} +""" + +# ========================================== +# Settings Dialog Window +# ========================================== +class SettingsDialog(QDialog): + def __init__(self, parent=None, tray_app=None): + super().__init__(parent) + self.tray_app = tray_app + self.setWindowTitle("f0ckm Uploader Settings") + self.resize(520, 560) + self.setStyleSheet(STYLE_SHEET) + + self.tester_thread = None + self.tester = None + + self.init_ui() + + def init_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(15) + + # Header + header_layout = QHBoxLayout() + header_icon = QLabel() + icon = QIcon.fromTheme("preferences-system", QIcon.fromTheme("network-server")) + header_icon.setPixmap(icon.pixmap(32, 32)) + + header_text_layout = QVBoxLayout() + header_title = QLabel("f0ckm Uploader") + header_title.setObjectName("title") + header_subtitle = QLabel("Configure connection settings and default options") + header_subtitle.setStyleSheet("color: #94a1b2; font-size: 11px;") + header_text_layout.addWidget(header_title) + header_text_layout.addWidget(header_subtitle) + + header_layout.addWidget(header_icon) + header_layout.addLayout(header_text_layout) + header_layout.addStretch() + main_layout.addLayout(header_layout) + + # Section 1: API Connection Settings + grp_api = QGroupBox("API Connection") + api_layout = QFormLayout(grp_api) + api_layout.setContentsMargins(15, 20, 15, 15) + api_layout.setSpacing(10) + + self.txt_url = QLineEdit() + self.txt_url.setPlaceholderText("https://example.com/api/v2/upload") + 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") + key_layout.addWidget(self.txt_key) + + self.btn_toggle_key = QPushButton("Show") + self.btn_toggle_key.setFixedWidth(60) + self.btn_toggle_key.clicked.connect(self.toggle_key_visibility) + key_layout.addWidget(self.btn_toggle_key) + api_layout.addRow("API Key:", key_layout) + + test_layout = QHBoxLayout() + test_layout.addStretch() + self.btn_test = QPushButton("Test Connection") + self.btn_test.setObjectName("test") + self.btn_test.clicked.connect(self.on_test_connection) + test_layout.addWidget(self.btn_test) + api_layout.addRow("", test_layout) + + main_layout.addWidget(grp_api) + + # Section 2: Default Parameters + grp_defaults = QGroupBox("Default Parameters") + defaults_layout = QFormLayout(grp_defaults) + defaults_layout.setContentsMargins(15, 20, 15, 15) + defaults_layout.setSpacing(10) + + self.cb_rating = QComboBox() + self.cb_rating.addItems(["(None / Default)", "safe", "questionable", "explicit"]) + defaults_layout.addRow("Default Rating:", self.cb_rating) + + self.txt_tags = QLineEdit() + self.txt_tags.setPlaceholderText("comma-separated tags (e.g., meme, anime)") + defaults_layout.addRow("Default Tags:", self.txt_tags) + + self.chk_oc = QCheckBox("Mark upload as Original Content (is_oc=1)") + defaults_layout.addRow("", self.chk_oc) + + main_layout.addWidget(grp_defaults) + + # Section 3: App Behavior + grp_behavior = QGroupBox("App Behavior") + behavior_layout = QVBoxLayout(grp_behavior) + behavior_layout.setContentsMargins(15, 20, 15, 15) + behavior_layout.setSpacing(10) + + self.chk_notifications = QCheckBox("Enable Desktop Notifications") + self.chk_progress = QCheckBox("Show Live Progress Dialog") + self.chk_autostart = QCheckBox("Start automatically on system login") + + behavior_layout.addWidget(self.chk_notifications) + behavior_layout.addWidget(self.chk_progress) + behavior_layout.addWidget(self.chk_autostart) + + # Icon Theme Selection + theme_layout = QHBoxLayout() + theme_label = QLabel("Tray Icon Theme:") + self.cb_icon_theme = QComboBox() + self.cb_icon_theme.addItems(["Dark (Black Sun)", "Light (White Sun)"]) + theme_layout.addWidget(theme_label) + theme_layout.addWidget(self.cb_icon_theme) + theme_layout.addStretch() + behavior_layout.addLayout(theme_layout) + + main_layout.addWidget(grp_behavior) + + # Save / Cancel Buttons + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + btn_cancel = QPushButton("Cancel") + btn_cancel.clicked.connect(self.reject) + + self.btn_save = QPushButton("Save Settings") + self.btn_save.setObjectName("primary") + self.btn_save.clicked.connect(self.save_settings) + + btn_layout.addWidget(btn_cancel) + btn_layout.addWidget(self.btn_save) + main_layout.addLayout(btn_layout) + + self.load_current_settings() + + def toggle_key_visibility(self): + if self.txt_key.echoMode() == QLineEdit.Password: + self.txt_key.setEchoMode(QLineEdit.Normal) + self.btn_toggle_key.setText("Hide") + else: + self.txt_key.setEchoMode(QLineEdit.Password) + self.btn_toggle_key.setText("Show") + + def load_current_settings(self): + config = load_config() + self.txt_url.setText(config.get("api_url", "")) + self.txt_key.setText(config.get("api_key", "")) + + rating = config.get("default_rating", "") + rating_map = {"": 0, "safe": 1, "questionable": 2, "explicit": 3} + self.cb_rating.setCurrentIndex(rating_map.get(rating, 0)) + + self.txt_tags.setText(config.get("default_tags", "")) + self.chk_oc.setChecked(config.get("default_is_oc", False)) + self.chk_notifications.setChecked(config.get("enable_notifications", True)) + self.chk_progress.setChecked(config.get("show_progress_dialog", True)) + self.chk_autostart.setChecked(config.get("autostart", False)) + + icon_theme = config.get("icon_theme", "dark").lower() + self.cb_icon_theme.setCurrentIndex(1 if icon_theme == "light" else 0) + + def on_test_connection(self): + self.btn_test.setEnabled(False) + self.btn_test.setText("Testing...") + + url = self.txt_url.text().strip() + key = self.txt_key.text().strip() + + self.tester_thread = QThread() + self.tester = ConnectionTester(url, key) + self.tester.moveToThread(self.tester_thread) + + self.tester_thread.started.connect(self.tester.run) + self.tester.finished.connect(self.on_test_finished) + self.tester.finished.connect(self.tester_thread.quit) + self.tester.finished.connect(self.tester.deleteLater) + self.tester_thread.finished.connect(self.tester_thread.deleteLater) + + self.tester_thread.start() + + def on_test_finished(self, success, message): + self.btn_test.setEnabled(True) + self.btn_test.setText("Test Connection") + + if success: + QMessageBox.information(self, "Connection Test Success", message) + else: + QMessageBox.warning(self, "Connection Test Failed", message) + + def save_settings(self): + config = { + "api_url": self.txt_url.text().strip(), + "api_key": self.txt_key.text().strip(), + "default_rating": ["", "safe", "questionable", "explicit"][self.cb_rating.currentIndex()], + "default_tags": self.txt_tags.text().strip(), + "default_is_oc": self.chk_oc.isChecked(), + "enable_notifications": self.chk_notifications.isChecked(), + "show_progress_dialog": self.chk_progress.isChecked(), + "autostart": self.chk_autostart.isChecked(), + "icon_theme": "light" if self.cb_icon_theme.currentIndex() == 1 else "dark" + } + + 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() + self.accept() + else: + QMessageBox.critical(self, "Error", "Failed to save configuration file.") + +# ========================================== +# Dynamic Tray Digit Icon Painter +# ========================================== +def create_digit_icon(percent: int) -> QIcon: + size = 64 + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) + + # Sharp-edged solid black badge + painter.setBrush(QColor(0, 0, 0, 255)) + painter.setPen(QPen(QColor(0, 0, 0), 2)) + painter.drawRect(2, 2, size - 4, size - 4) + + # Crisp white digits (0 - 99) + val_str = str(min(99, max(0, percent))) + font_size = 32 if len(val_str) == 1 else 26 + font = QFont("Sans-Serif", font_size, QFont.Weight.Bold) + painter.setFont(font) + painter.setPen(QColor(255, 255, 255)) + + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, val_str) + painter.end() + + return QIcon(pixmap) + +# ========================================== +# Thumbnail Generator Helper +# ========================================== +def generate_thumbnail(file_path: str) -> str: + if not file_path or not os.path.exists(file_path): + return "" + + thumb_path = os.path.join(tempfile.gettempdir(), f"f0ckm_thumb_{int(time.time())}.png") + ext = os.path.splitext(file_path)[1].lower() + + # 1. Video files -> Extract thumbnail frame using ffmpeg + video_exts = {".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv", ".m4v", ".wmv"} + if ext in video_exts: + try: + res = subprocess.run( + ["ffmpeg", "-y", "-ss", "00:00:01", "-i", file_path, "-vframes", "1", + "-vf", "scale=160:80:force_original_aspect_ratio=increase,crop=160:80", thumb_path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5 + ) + if res.returncode == 0 and os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 0: + return thumb_path + except Exception: + pass + + # 2. Image files -> Native fast PySide6 scaling + image_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".avif"} + if ext in image_exts: + try: + img = QImage(file_path) + if not img.isNull(): + scaled = img.scaled(160, 80, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) + x = max(0, (scaled.width() - 160) // 2) + y = max(0, (scaled.height() - 80) // 2) + cropped = scaled.copy(x, y, 160, 80) + if cropped.save(thumb_path, "PNG"): + return thumb_path + except Exception: + pass + + # 3. Non-image / Non-video -> Create sleek placeholder badge + try: + pixmap = QPixmap(160, 80) + pixmap.fill(QColor(24, 24, 30)) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + painter.setPen(QPen(QColor(60, 60, 75), 2)) + painter.drawRect(1, 1, 158, 78) + + display_ext = ext.replace(".", "").upper()[:5] or "FILE" + painter.setFont(QFont("Sans-Serif", 16, QFont.Weight.Bold)) + painter.setPen(QColor(220, 220, 240)) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, display_ext) + painter.end() + + if pixmap.save(thumb_path, "PNG"): + return thumb_path + except Exception: + pass + + return "" + +# ========================================== +# System Tray Application Manager +# ========================================== +class SystemTrayApp(QObject): + def __init__(self): + super().__init__() + self.tray = QSystemTrayIcon() + + self.load_tray_icon() + self.tray.setToolTip("f0ckm Uploader (Click to capture & upload)") + + self.network_manager = QNetworkAccessManager(self) + self.current_reply = None + + self.menu = QMenu() + + # Header entry + title_action = QAction("f0ckm Uploader", self.menu) + title_action.setEnabled(False) + self.menu.addAction(title_action) + self.menu.addSeparator() + + # Actions + capture_action = QAction("Capture Region & Upload", self.menu) + capture_action.triggered.connect(self.on_capture_and_upload) + self.menu.addAction(capture_action) + + upload_action = QAction("Upload File...", self.menu) + upload_action.triggered.connect(self.on_upload_file) + self.menu.addAction(upload_action) + + clipboard_action = QAction("Upload from Clipboard", self.menu) + clipboard_action.triggered.connect(self.on_upload_clipboard) + self.menu.addAction(clipboard_action) + + settings_action = QAction("Settings...", self.menu) + settings_action.triggered.connect(self.on_open_settings) + self.menu.addAction(settings_action) + + self.menu.addSeparator() + + quit_action = QAction("Quit", self.menu) + quit_action.triggered.connect(QApplication.quit) + self.menu.addAction(quit_action) + + self.tray.setContextMenu(self.menu) + self.tray.activated.connect(self.on_tray_activated) + + self.settings_dialog = None + + def show(self): + self.tray.show() + + def on_tray_activated(self, reason): + if reason == QSystemTrayIcon.Trigger: + # Single click on tray icon starts Spectacle capture & upload + self.on_capture_and_upload() + elif reason == QSystemTrayIcon.DoubleClick: + self.on_open_settings() + + def load_tray_icon(self): + config = get_env_config() + theme = config.get("icon_theme", "dark").lower() + filename = "icon_light.svg" if theme == "light" else "icon.svg" + + script_dir = os.path.dirname(os.path.abspath(__file__)) + candidates = [ + os.path.join(script_dir, filename), + f"/home/kibi/Projects/f0ckm-uploader/{filename}", + os.path.expanduser(f"~/.local/bin/{filename}") + ] + icon_path = None + for c in candidates: + if os.path.exists(c): + icon_path = c + break + + if icon_path: + self.default_icon = QIcon(icon_path) + else: + self.default_icon = QIcon.fromTheme("cloud-upload-symbolic", QIcon.fromTheme("network-server")) + + self.tray.setIcon(self.default_icon) + + def on_open_settings(self): + if not self.settings_dialog: + self.settings_dialog = SettingsDialog(tray_app=self) + self.settings_dialog.show() + self.settings_dialog.raise_() + self.settings_dialog.activateWindow() + + def on_capture_and_upload(self): + screenshot_dir = os.path.expanduser("~/Pictures/Screenshots") + os.makedirs(screenshot_dir, exist_ok=True) + save_path = os.path.join(screenshot_dir, f"screenshot_{time.strftime('%Y%m%d_%H%M%S')}.png") + + try: + res = subprocess.run(["spectacle", "-r", "-b", "-n", "-o", save_path]) + if os.path.exists(save_path) and os.path.getsize(save_path) > 0: + self.upload_file_direct(save_path) + except Exception as e: + self.show_message("Upload Error", f"Failed to capture screenshot: {e}", QSystemTrayIcon.Critical) + + def upload_file_direct(self, file_path): + file_path = normalize_file_path(file_path) + if not file_path or not os.path.exists(file_path): + self.show_message("Upload Error", f"File not found: {file_path}", QSystemTrayIcon.Critical) + return + + config = get_env_config() + api_url = config.get("api_url", "") + api_key = config.get("api_key", "") + rating = config.get("default_rating", "") + tags = config.get("default_tags", "") + visibility = str(config.get("default_visibility", "0")) + is_oc = "1" if config.get("default_is_oc", False) else "0" + + self.tray.setIcon(create_digit_icon(0)) + self.tray.setToolTip("Uploading: 0%") + + request = QNetworkRequest(QUrl(api_url)) + if api_key: + request.setRawHeader(b"X-Api-Key", api_key.encode("utf-8")) + + multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType) + + file_part = QHttpPart() + filename = os.path.basename(file_path) + file_part.setHeader(QNetworkRequest.ContentDispositionHeader, f'form-data; name="file"; filename="{filename}"') + + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + mime_type = "image/png" if file_path.lower().endswith(".png") else "application/octet-stream" + file_part.setHeader(QNetworkRequest.ContentTypeHeader, mime_type) + + qfile = QFile(file_path) + if not qfile.open(QIODevice.ReadOnly): + self.tray.setIcon(self.default_icon) + self.show_message("Upload Error", f"Cannot open file: {file_path}", QSystemTrayIcon.Critical) + return + + file_part.setBodyDevice(qfile) + qfile.setParent(multi_part) + multi_part.append(file_part) + + for name, val in [("rating", rating), ("tags", tags), ("visibility", visibility), ("is_oc", is_oc)]: + if val != "": + p = QHttpPart() + p.setHeader(QNetworkRequest.ContentDispositionHeader, f'form-data; name="{name}"') + p.setBody(str(val).encode("utf-8")) + multi_part.append(p) + + reply = self.network_manager.post(request, multi_part) + multi_part.setParent(reply) + reply.qfile = qfile + reply.multi_part = multi_part + self.current_reply = reply + + reply.uploadProgress.connect(self.on_upload_progress) + reply.finished.connect(lambda: self.on_upload_finished(reply, file_path)) + + def on_upload_progress(self, bytes_sent, bytes_total): + if bytes_total > 0: + pct = min(100, max(0, int((bytes_sent / bytes_total) * 100))) + self.tray.setIcon(create_digit_icon(pct)) + self.tray.setToolTip(f"Uploading: {pct}%") + + def on_upload_finished(self, reply, file_path): + self.tray.setIcon(self.default_icon) + self.tray.setToolTip("f0ckm Uploader (Click to capture & upload)") + + try: + err_body = reply.readAll().data().decode("utf-8", errors="ignore") + + if reply.error() == QNetworkReply.NoError: + try: + res = json.loads(err_body) + if res.get("success") and res.get("url"): + item_url = res.get("url") + QApplication.clipboard().setText(item_url) + self.show_success_notification(file_path, item_url) + else: + msg = res.get("msg", "Upload failed") + self.show_message("Upload Failed", msg, QSystemTrayIcon.Critical) + except Exception as e: + self.show_message("Upload Error", f"Error parsing response: {e}", QSystemTrayIcon.Critical) + else: + server_msg = err_body if err_body else reply.errorString() + self.show_message("Upload Failed", f"Server Response: {server_msg}", QSystemTrayIcon.Critical) + except Exception as e: + self.show_message("Upload Error", f"Unexpected error: {e}", QSystemTrayIcon.Critical) + finally: + reply.deleteLater() + self.current_reply = None + + def show_success_notification(self, file_path, item_url): + config = get_env_config() + if not config.get("enable_notifications", True): + return + + thumb_path = generate_thumbnail(file_path) + if thumb_path and os.path.exists(thumb_path): + body_text = f'
| Uploading $wrapped_filename $detail |