962 lines
35 KiB
Python
962 lines
35 KiB
Python
#!/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()
|