update all components

This commit is contained in:
2026-08-11 21:59:25 +02:00
parent 81efd52a5e
commit 0f0d04af63
9 changed files with 1111 additions and 307 deletions

961
gui.py Normal file
View File

@@ -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'<div><a href="{item_url}"><img src="file://{thumb_path}" height="80" style="height:80px; max-width:100%; object-fit:cover;" /></a></div><br/><br/><a href="{item_url}">{item_url}</a>'
else:
body_text = f'Upload Successful: <a href="{item_url}">{item_url}</a>'
try:
subprocess.run(["notify-send", "-a", "f0ckm", "Upload Successful", body_text], check=False)
except Exception as e:
print(f"Notification error: {e}")
def on_upload_file(self):
file_path, _ = QFileDialog.getOpenFileName(
None, "Select File to Upload", "", "All Files (*)"
)
if file_path:
self.upload_file_direct(file_path)
def on_upload_clipboard(self):
clipboard = QApplication.clipboard()
mime_data = clipboard.mimeData()
if mime_data.hasImage():
image = clipboard.image()
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, f"f0ckm_clipboard_{int(time.time())}.png")
if image.save(temp_path, "PNG"):
self.upload_file_direct(temp_path)
else:
self.show_message("Upload Error", "Failed to save clipboard image to temporary file.", QSystemTrayIcon.Critical)
elif mime_data.hasUrls():
urls = mime_data.urls()
uploaded_any = False
for url in urls:
if url.isLocalFile():
path = url.toLocalFile()
if os.path.exists(path):
self.upload_file_direct(path)
uploaded_any = True
if not uploaded_any:
self.show_message("Upload Error", "No valid local files in clipboard.", QSystemTrayIcon.Warning)
elif mime_data.hasText():
text = mime_data.text().strip()
lines = [l.strip() for l in text.split('\n') if l.strip()]
uploaded_any = False
for line in lines:
if line.startswith("file://"):
line = line[7:]
if os.path.exists(line):
self.upload_file_direct(line)
uploaded_any = True
if not uploaded_any:
self.show_message("Upload Error", "Clipboard text is not a valid local file path.", QSystemTrayIcon.Warning)
else:
self.show_message("Upload Error", "Clipboard does not contain an image or file paths.", QSystemTrayIcon.Warning)
def show_message(self, title, message, icon=QSystemTrayIcon.Information):
self.tray.showMessage(title, message, icon, 5000)
# ==========================================
# Application Entry Point & IPC
# ==========================================
class SingleInstanceApp:
def __init__(self, name):
self.name = name
self.server = QLocalServer()
def start(self, callback_on_message):
if not self.server.listen(self.name):
socket = QLocalSocket()
socket.connectToServer(self.name)
if socket.waitForConnected(500):
msg = "show"
if len(sys.argv) > 1:
arg = sys.argv[1]
if arg in ("--spectacle", "-s"):
msg = "spectacle"
elif arg.startswith("--upload="):
msg = f"upload:{arg[9:]}"
elif arg.startswith("--upload"):
if len(sys.argv) > 2:
msg = f"upload:{sys.argv[2]}"
else:
norm = normalize_file_path(arg)
if os.path.exists(norm):
msg = f"upload:{norm}"
socket.write(msg.encode("utf-8"))
socket.waitForBytesWritten(500)
socket.disconnectFromServer()
return False
else:
QLocalServer.removeServer(self.name)
if not self.server.listen(self.name):
return False
self.server.newConnection.connect(lambda: self._handle_connection(callback_on_message))
return True
def _handle_connection(self, callback):
socket = self.server.nextPendingConnection()
if socket:
socket.readyRead.connect(lambda: self._read_message(socket, callback))
def _read_message(self, socket, callback):
data = socket.readAll().data().decode("utf-8").strip()
callback(data)
socket.disconnectFromServer()
def main():
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
instance_name = "f0ckm-uploader-gui-lock"
single_instance = SingleInstanceApp(instance_name)
tray_app = None
def on_activate(msg_str):
nonlocal tray_app
if tray_app:
if msg_str == "spectacle":
tray_app.on_capture_and_upload()
elif msg_str.startswith("upload:"):
file_path = normalize_file_path(msg_str[7:])
if os.path.exists(file_path):
tray_app.upload_file_direct(file_path)
else:
tray_app.show_message("Upload Error", f"File not found: {file_path}", QSystemTrayIcon.Critical)
else:
tray_app.on_open_settings()
if not single_instance.start(on_activate):
sys.exit(0)
if not os.path.exists(CONFIG_PATH):
save_config(DEFAULT_CONFIG)
tray_app = SystemTrayApp()
tray_app.show()
# Check if started with action argument
if len(sys.argv) > 1:
arg = sys.argv[1]
if arg in ("--spectacle", "-s"):
tray_app.on_capture_and_upload()
elif arg.startswith("--upload="):
file_path = normalize_file_path(arg[9:])
tray_app.upload_file_direct(file_path)
elif arg.startswith("--upload"):
if len(sys.argv) > 2:
file_path = normalize_file_path(sys.argv[2])
tray_app.upload_file_direct(file_path)
else:
norm = normalize_file_path(arg)
if os.path.exists(norm):
tray_app.upload_file_direct(norm)
sys.exit(app.exec())
if __name__ == "__main__":
main()