update all components
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
961
gui.py
Normal file
961
gui.py
Normal 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()
|
||||
108
install.sh
Normal file → Executable file
108
install.sh
Normal file → Executable file
@@ -9,22 +9,110 @@ LEGACY_SERVICE_MENU_DIR="$HOME/.local/share/kservices5/ServiceMenus"
|
||||
# Create necessary directories
|
||||
mkdir -p "$INSTALL_DIR" "$APP_DIR" "$SERVICE_MENU_DIR" "$LEGACY_SERVICE_MENU_DIR"
|
||||
|
||||
# 1. Install the script
|
||||
# 1. Install the scripts
|
||||
cp uploader.sh "$INSTALL_DIR/kde-uploader"
|
||||
chmod +x "$INSTALL_DIR/kde-uploader"
|
||||
|
||||
# 2. Install standard application entry (for Spectacle)
|
||||
sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader|g" kde-uploader.desktop > "$APP_DIR/kde-uploader.desktop"
|
||||
chmod +x "$APP_DIR/kde-uploader.desktop"
|
||||
update-desktop-database "$APP_DIR" &> /dev/null
|
||||
cp gui.py "$INSTALL_DIR/kde-uploader-gui"
|
||||
chmod +x "$INSTALL_DIR/kde-uploader-gui"
|
||||
|
||||
# 3. Install KDE Service Menu entry (for Dolphin)
|
||||
sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader|g" kde-uploader-servicemenu.desktop > "$SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop"
|
||||
mkdir -p "$HOME/.local/share/icons/hicolor/scalable/apps"
|
||||
mkdir -p "$HOME/.local/share/pixmaps"
|
||||
|
||||
# Generate multi-resolution PNG icons (32x32, 48x48, 64x64, 128x128, 256x256) for KRunner search compatibility
|
||||
python3 -c '
|
||||
import os
|
||||
from PySide6.QtGui import QImage, QPainter
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
try:
|
||||
renderer = QSvgRenderer("icon_app.svg")
|
||||
sizes = [32, 48, 64, 128, 256]
|
||||
for sz in sizes:
|
||||
dir_path = f"'$HOME'/.local/share/icons/hicolor/{sz}x{sz}/apps"
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
img = QImage(sz, sz, QImage.Format_ARGB32)
|
||||
img.fill(0)
|
||||
p = QPainter(img)
|
||||
renderer.render(p)
|
||||
p.end()
|
||||
img.save(os.path.join(dir_path, "f0ckm-uploader.png"))
|
||||
|
||||
img_48 = QImage(48, 48, QImage.Format_ARGB32)
|
||||
img_48.fill(0)
|
||||
p = QPainter(img_48)
|
||||
renderer.render(p)
|
||||
p.end()
|
||||
img_48.save("'$HOME'/.local/share/pixmaps/f0ckm-uploader.png")
|
||||
except Exception as e:
|
||||
pass
|
||||
' &> /dev/null
|
||||
|
||||
if [ -f icon_app.svg ]; then
|
||||
cp icon_app.svg "$INSTALL_DIR/icon_app.svg"
|
||||
cp icon_app.svg "$HOME/.local/share/icons/hicolor/scalable/apps/f0ckm-uploader.svg"
|
||||
elif [ -f icon.svg ]; then
|
||||
cp icon.svg "$HOME/.local/share/icons/hicolor/scalable/apps/f0ckm-uploader.svg"
|
||||
fi
|
||||
|
||||
if [ -f icon.svg ]; then
|
||||
cp icon.svg "$INSTALL_DIR/icon.svg"
|
||||
fi
|
||||
|
||||
if [ -f icon_light.svg ]; then
|
||||
cp icon_light.svg "$INSTALL_DIR/icon_light.svg"
|
||||
fi
|
||||
|
||||
if [ -f .env ]; then
|
||||
cp .env "$INSTALL_DIR/.env"
|
||||
fi
|
||||
|
||||
# Clean up any stale auto-generated desktop entries
|
||||
rm -f "$APP_DIR/net.local.spectacle_uploader.sh.desktop"
|
||||
|
||||
ICON_PATH="f0ckm-uploader"
|
||||
|
||||
# 2. Install standard application entry (for Spectacle - hidden from main menu)
|
||||
sed -e "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader-gui --spectacle|g" -e "s|__ICON_PATH__|$ICON_PATH|g" kde-uploader.desktop > "$APP_DIR/kde-uploader.desktop"
|
||||
chmod +x "$APP_DIR/kde-uploader.desktop"
|
||||
|
||||
# 3. Install the GUI application entry (visible in main menu)
|
||||
sed -e "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader-gui|g" -e "s|__ICON_PATH__|$ICON_PATH|g" kde-uploader-gui.desktop > "$APP_DIR/kde-uploader-gui.desktop"
|
||||
chmod +x "$APP_DIR/kde-uploader-gui.desktop"
|
||||
|
||||
update-desktop-database "$APP_DIR" &> /dev/null
|
||||
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" &> /dev/null || true
|
||||
kbuildsycoca6 --noincremental &> /dev/null || kbuildsycoca5 --noincremental &> /dev/null || true
|
||||
|
||||
# 4. Install KDE Service Menu entry (for Dolphin)
|
||||
sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader-gui --upload|g" kde-uploader-servicemenu.desktop > "$SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop"
|
||||
chmod +x "$SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop"
|
||||
sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader|g" kde-uploader-servicemenu.desktop > "$LEGACY_SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop"
|
||||
sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader-gui --upload|g" kde-uploader-servicemenu.desktop > "$LEGACY_SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop"
|
||||
chmod +x "$LEGACY_SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop"
|
||||
|
||||
# 5. Migrate config to config.json if not present
|
||||
CONFIG_DIR="$HOME/.config/f0ckm-uploader"
|
||||
CONFIG_FILE="$CONFIG_DIR/config.json"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
# Fallback to the default key in script if no config file is found
|
||||
cat <<EOF > "$CONFIG_FILE"
|
||||
{
|
||||
"api_url": "",
|
||||
"api_key": "",
|
||||
"default_rating": "",
|
||||
"default_tags": "",
|
||||
"default_is_oc": false,
|
||||
"enable_notifications": true,
|
||||
"show_progress_dialog": true,
|
||||
"autostart": false
|
||||
}
|
||||
EOF
|
||||
echo "Initial configuration created at $CONFIG_FILE"
|
||||
fi
|
||||
|
||||
echo "Installation complete!"
|
||||
echo "The script has been installed to $INSTALL_DIR/kde-uploader"
|
||||
echo "You can now right click files in Dolphin and find 'Upload to Custom API' in the Actions menu."
|
||||
echo "In Spectacle, you should find 'Upload to Custom API' in the Export or Share menu."
|
||||
echo "The GUI has been installed to $INSTALL_DIR/kde-uploader-gui"
|
||||
echo "You can launch the GUI uploader from your applications menu (f0ckm Uploader GUI)."
|
||||
echo "You can now right click files in Dolphin and find 'Upload to f0ckm' in the Actions menu."
|
||||
echo "In Spectacle, you should find 'Upload to f0ckm' in the Export or Share menu."
|
||||
|
||||
17
kde-uploader-gui.desktop
Executable file
17
kde-uploader-gui.desktop
Executable file
@@ -0,0 +1,17 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=f0ckm Uploader
|
||||
GenericName=Image & Media Uploader
|
||||
Comment=System tray GUI uploader for f0ckm
|
||||
Exec=__EXEC_PATH__
|
||||
Icon=__ICON_PATH__
|
||||
Terminal=false
|
||||
Categories=Utility;Network;
|
||||
Keywords=upload;screenshot;f0ckm;tray;
|
||||
StartupNotify=false
|
||||
Actions=capture;
|
||||
|
||||
[Desktop Action capture]
|
||||
Name=Capture Region & Upload
|
||||
Exec=__EXEC_PATH__ --spectacle
|
||||
Icon=__ICON_PATH__
|
||||
@@ -9,4 +9,4 @@ X-KDE-Submenu=Upload
|
||||
[Desktop Action upload_custom_api]
|
||||
Name=Upload to f0ckm
|
||||
Icon=cloud-upload-symbolic
|
||||
Exec=__EXEC_PATH__ "%u"
|
||||
Exec=__EXEC_PATH__ "%f"
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=f0ckm Uploader
|
||||
Comment=Upload to f0ckm
|
||||
Exec=__EXEC_PATH__ %F
|
||||
Icon=network-server
|
||||
Name=f0ckm Uploader (Internal)
|
||||
Comment=Upload to f0ckm (Backend registration)
|
||||
Exec=__EXEC_PATH__
|
||||
Icon=f0ckm-uploader
|
||||
Terminal=false
|
||||
Categories=Utility;Network;
|
||||
MimeType=image/png;image/jpeg;image/gif;image/webp;image/bmp;image/tiff;image/avif;image/heic;video/mp4;video/webm;audio/mpeg;audio/ogg;
|
||||
NoDisplay=false
|
||||
NoDisplay=true
|
||||
InitialPreference=0
|
||||
|
||||
[Desktop Action upload]
|
||||
Name=Upload to Custom API
|
||||
Exec=__EXEC_PATH__ %F
|
||||
Icon=network-server
|
||||
Exec=__EXEC_PATH__
|
||||
Icon=f0ckm-uploader
|
||||
|
||||
@@ -1,93 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ── 0. Load Environment Variables from .env ─────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOTENV_PATH="${SCRIPT_DIR}/.env"
|
||||
|
||||
if [ -f "$DOTENV_PATH" ]; then
|
||||
set -o allexport
|
||||
source "$DOTENV_PATH"
|
||||
set +o allexport
|
||||
fi
|
||||
|
||||
# Environment variables from .env
|
||||
F0CKM_URL="${F0CKM_URL}"
|
||||
API_KEY="${API_KEY}"
|
||||
RATING="${RATING}" # sfw, nsfw, or nsfl
|
||||
TAGS="${TAGS}" # Comma-separated tags
|
||||
VISIBILITY="${VISIBILITY}" # 0 = public, 1 = unlisted, 2 = private
|
||||
|
||||
# Notification app header title & preview dimensions
|
||||
APP_NAME="${APP_NAME:-f0ckm}"
|
||||
PREVIEW_HEIGHT="${PREVIEW_HEIGHT:-80}" # Center crop height in pixels (e.g. 80)
|
||||
|
||||
# ── 1. Clipboard Helper Function (Wayland / X11) ───────────────────────────
|
||||
copy_to_clipboard() {
|
||||
local content="$1"
|
||||
if command -v wl-copy &>/dev/null; then
|
||||
echo -n "$content" | wl-copy
|
||||
elif command -v xclip &>/dev/null; then
|
||||
echo -n "$content" | xclip -selection clipboard
|
||||
elif command -v xsel &>/dev/null; then
|
||||
echo -n "$content" | xsel -b
|
||||
else
|
||||
echo "Warning: No clipboard utility found (wl-clipboard or xclip)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 1. Create Output Directory & Timestamped File Path ───────────────────
|
||||
SCREENSHOT_DIR="${HOME}/Pictures/Screenshots"
|
||||
mkdir -p "$SCREENSHOT_DIR"
|
||||
|
||||
SAVE_PATH="${SCREENSHOT_DIR}/screenshot_$(date +%Y%m%d_%H%M%S).png"
|
||||
|
||||
# ── 2. Capture Region Screenshot with Spectacle ──────────────────────────
|
||||
spectacle -r -b -n -o "$SAVE_PATH"
|
||||
|
||||
# ── 3. Upload File & Process Response ────────────────────────────────────
|
||||
if [ -f "$SAVE_PATH" ]; then
|
||||
echo "Uploading '$SAVE_PATH' to ${F0CKM_URL}/api/v2/upload..."
|
||||
|
||||
RESPONSE=$(curl -s -X POST "${F0CKM_URL}/api/v2/upload" \
|
||||
-H "X-Api-Key: ${API_KEY}" \
|
||||
-F "file=@${SAVE_PATH}" \
|
||||
-F "rating=${RATING}" \
|
||||
-F "tags=${TAGS}" \
|
||||
-F "visibility=${VISIBILITY}")
|
||||
|
||||
# Parse response JSON
|
||||
SUCCESS=$(echo "$RESPONSE" | jq -r '.success // false')
|
||||
ITEM_URL=$(echo "$RESPONSE" | jq -r '.url // empty')
|
||||
MSG=$(echo "$RESPONSE" | jq -r '.msg // "Upload failed"')
|
||||
|
||||
if [ "$SUCCESS" = "true" ] && [ -n "$ITEM_URL" ]; then
|
||||
# Copy URL to clipboard
|
||||
copy_to_clipboard "$ITEM_URL"
|
||||
|
||||
# Create center-cropped preview banner (exact 80px height)
|
||||
PREVIEW_THUMB="/tmp/f0ckm_preview.png"
|
||||
if command -v magick &>/dev/null; then
|
||||
magick "$SAVE_PATH" -gravity center -crop "x${PREVIEW_HEIGHT}+0+0" +repage "$PREVIEW_THUMB"
|
||||
else
|
||||
PREVIEW_THUMB="$SAVE_PATH"
|
||||
fi
|
||||
|
||||
# HTML body with fixed height preview banner (no scrolling), and link on a new line
|
||||
BODY_TEXT="<div><a href=\"${ITEM_URL}\"><img src=\"file://${PREVIEW_THUMB}\" height=\"${PREVIEW_HEIGHT}\" style=\"height:${PREVIEW_HEIGHT}px; max-width:100%; object-fit:cover;\" /></a></div><br/><br/><a href=\"${ITEM_URL}\">${ITEM_URL}</a>"
|
||||
|
||||
# Send Notification
|
||||
notify-send \
|
||||
-a "${APP_NAME}" \
|
||||
"" \
|
||||
"${BODY_TEXT}"
|
||||
|
||||
echo "Success! $ITEM_URL"
|
||||
else
|
||||
# Show error notification
|
||||
notify-send -a "${APP_NAME}" -u critical "Upload Failed" "$MSG"
|
||||
echo "Upload failed: $RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Screenshot capture canceled."
|
||||
fi
|
||||
# Pass execution directly to the Python GUI engine
|
||||
exec kde-uploader-gui --spectacle "$@"
|
||||
|
||||
25
uninstall.sh
25
uninstall.sh
@@ -6,17 +6,27 @@ APP_DIR="$HOME/.local/share/applications"
|
||||
SERVICE_MENU_DIR="$HOME/.local/share/kio/servicemenus"
|
||||
LEGACY_SERVICE_MENU_DIR="$HOME/.local/share/kservices5/ServiceMenus"
|
||||
|
||||
# 1. Remove the executable script
|
||||
# 1. Remove the executable scripts
|
||||
if [ -f "$INSTALL_DIR/kde-uploader" ]; then
|
||||
rm "$INSTALL_DIR/kde-uploader"
|
||||
echo "Removed executable from $INSTALL_DIR"
|
||||
echo "Removed uploader executable from $INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# 2. Remove the standard application entry
|
||||
if [ -f "$INSTALL_DIR/kde-uploader-gui" ]; then
|
||||
rm "$INSTALL_DIR/kde-uploader-gui"
|
||||
echo "Removed GUI executable from $INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# 2. Remove the application entries
|
||||
if [ -f "$APP_DIR/kde-uploader.desktop" ]; then
|
||||
rm "$APP_DIR/kde-uploader.desktop"
|
||||
echo "Removed application entry from $APP_DIR"
|
||||
fi
|
||||
|
||||
if [ -f "$APP_DIR/kde-uploader-gui.desktop" ]; then
|
||||
rm "$APP_DIR/kde-uploader-gui.desktop"
|
||||
echo "Removed GUI application entry from $APP_DIR"
|
||||
fi
|
||||
update-desktop-database "$APP_DIR" &> /dev/null
|
||||
|
||||
# 3. Remove the modern KDE Service Menu entry
|
||||
@@ -31,4 +41,11 @@ if [ -f "$LEGACY_SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop" ]; then
|
||||
echo "Removed legacy service menu entry from $LEGACY_SERVICE_MENU_DIR"
|
||||
fi
|
||||
|
||||
echo "Uninstallation complete! The custom upload options and script have been removed."
|
||||
# 5. Remove autostart entry
|
||||
AUTOSTART_FILE="$HOME/.config/autostart/kde-uploader-gui.desktop"
|
||||
if [ -f "$AUTOSTART_FILE" ]; then
|
||||
rm "$AUTOSTART_FILE"
|
||||
echo "Removed autostart entry"
|
||||
fi
|
||||
|
||||
echo "Uninstallation complete! The custom upload options, GUI, and scripts have been removed."
|
||||
|
||||
196
uploader.sh
196
uploader.sh
@@ -1,193 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# Notification Wrapper
|
||||
# ==========================================
|
||||
show_notification() {
|
||||
local title="$1"
|
||||
local message="$2"
|
||||
local icon="${3:-cloud-upload-symbolic}"
|
||||
if command -v kdialog &>/dev/null; then
|
||||
kdialog --passivepopup "$message" 5 --title "$title" --icon "$icon"
|
||||
else
|
||||
echo "$title: $message"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Configuration
|
||||
# ==========================================
|
||||
API_URL="http://localhost:1337/api/v2/upload"
|
||||
API_KEY=""
|
||||
|
||||
# ==========================================
|
||||
|
||||
FILE="$1"
|
||||
|
||||
if [ -z "$FILE" ]; then
|
||||
show_notification "Upload Failed" "No file provided to uploader." "dialog-error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine clipboard command (Wayland or X11)
|
||||
if command -v wl-copy &>/dev/null; then
|
||||
COPY_CMD="wl-copy"
|
||||
elif command -v xclip &>/dev/null; then
|
||||
COPY_CMD="xclip -selection clipboard"
|
||||
else
|
||||
show_notification "Upload Error" "Please install wl-clipboard (Wayland) or xclip (X11)." "dialog-error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect the real MIME type so curl doesn't fall back to application/octet-stream
|
||||
MIME_TYPE=$(file --mime-type -b "$FILE")
|
||||
BASENAME=$(basename "$FILE")
|
||||
|
||||
# Get file size locally
|
||||
FILE_SIZE_BYTES=$(stat -c%s "$FILE" 2>/dev/null || echo "0")
|
||||
|
||||
human_readable_size() {
|
||||
local size=$1
|
||||
local units=('B' 'KB' 'MB' 'GB' 'TB')
|
||||
local unit=0
|
||||
while [ $size -ge 1024 ] && [ $unit -lt 4 ]; do
|
||||
size=$((size / 1024))
|
||||
unit=$((unit + 1))
|
||||
done
|
||||
echo "$size ${units[$unit]}"
|
||||
}
|
||||
TOTAL_SIZE=$(human_readable_size "$FILE_SIZE_BYTES")
|
||||
|
||||
# Helper to format centered HTML label with a fixed width
|
||||
format_label() {
|
||||
local filename="$1"
|
||||
local detail="$2"
|
||||
local wrapped_filename
|
||||
# Wrap the filename to 45 chars max, and join with <br>
|
||||
wrapped_filename=$(echo "$filename" | fold -w 45 | sed ':a;N;$!ba;s/\n/<br>/g')
|
||||
echo "<table width=\"400\"><tr><td align=\"center\">Uploading <b>$wrapped_filename</b><br><font color=\"#666666\">$detail</font></td></tr></table>"
|
||||
}
|
||||
|
||||
INIT_LABEL=$(format_label "$BASENAME" "(0 B / $TOTAL_SIZE) @ 0 B/s — --:--:-- remaining")
|
||||
|
||||
# ==========================================
|
||||
# Progress bar setup (kdialog + qdbus)
|
||||
# ==========================================
|
||||
|
||||
# Prefer qdbus6 on KDE6, fall back to qdbus on KDE5
|
||||
QDBUS_CMD=""
|
||||
if command -v qdbus6 &>/dev/null; then
|
||||
QDBUS_CMD="qdbus6"
|
||||
elif command -v qdbus &>/dev/null; then
|
||||
QDBUS_CMD="qdbus"
|
||||
fi
|
||||
|
||||
PROGRESS_SERVICE=""
|
||||
PROGRESS_OBJECT=""
|
||||
|
||||
if command -v kdialog &>/dev/null && [ -n "$QDBUS_CMD" ]; then
|
||||
DBUS_REF=$(kdialog --title "f0ckm Uploader" \
|
||||
--progressbar "$INIT_LABEL" 100 2>/dev/null)
|
||||
PROGRESS_SERVICE=$(echo "$DBUS_REF" | awk '{print $1}')
|
||||
PROGRESS_OBJECT=$(echo "$DBUS_REF" | awk '{print $2}')
|
||||
fi
|
||||
|
||||
# If no progress dialog is available, show a passive "uploading" popup instead
|
||||
if [ -z "$PROGRESS_SERVICE" ]; then
|
||||
show_notification "Uploading..." "Uploading $BASENAME" "cloud-upload-symbolic"
|
||||
fi
|
||||
|
||||
# ==========================================
|
||||
# Upload via curl with live progress
|
||||
# ==========================================
|
||||
|
||||
# FIFO routes curl's stderr (progress bar) to the reader loop
|
||||
FIFO=$(mktemp -u /tmp/f0ckm_XXXXXX)
|
||||
mkfifo "$FIFO"
|
||||
RESPONSE_TMP=$(mktemp)
|
||||
|
||||
# --progress-meter forces curl to output progress table even when redirected to a FIFO
|
||||
curl --progress-meter -w "\n%{http_code}" -X POST "$API_URL" \
|
||||
-H "X-Api-Key: $API_KEY" \
|
||||
-F "file=@$FILE;type=$MIME_TYPE" \
|
||||
-F "rating=" \
|
||||
-F "tags=" \
|
||||
-F "is_oc=0" \
|
||||
>"$RESPONSE_TMP" 2>"$FIFO" &
|
||||
CURL_PID=$!
|
||||
|
||||
# Parse the curl progress meter output in real-time
|
||||
if [ -n "$PROGRESS_SERVICE" ] && [ -n "$PROGRESS_OBJECT" ]; then
|
||||
while IFS= read -r -d $'\r' line; do
|
||||
# Clean line and split into fields
|
||||
line=$(echo "$line" | tr -d '\n\r')
|
||||
read -ra fields <<< "$line"
|
||||
|
||||
# Check if valid progress line: starts with percentage digit and has >= 11 fields
|
||||
if [[ ${#fields[@]} -ge 11 ]] && [[ "${fields[0]}" =~ ^[0-9]+$ ]]; then
|
||||
PCT="${fields[4]}" # % Xferd
|
||||
if [[ ! "$PCT" =~ ^[0-9]+$ ]]; then
|
||||
PCT="${fields[0]}"
|
||||
fi
|
||||
|
||||
TOTAL_SIZE_CURL="${fields[1]}"
|
||||
XFERD="${fields[5]}"
|
||||
SPEED="${fields[11]}"
|
||||
TIME_LEFT="${fields[10]}"
|
||||
|
||||
# Format speed (append /s if it looks like a size unit)
|
||||
if [[ "$SPEED" =~ [0-9]+[bBkKmMgGtT]?$ ]]; then
|
||||
SPEED="${SPEED}/s"
|
||||
fi
|
||||
|
||||
# Prefer the local size calculation if curl total size is 0/empty/not computed yet
|
||||
if [ -z "$TOTAL_SIZE_CURL" ] || [ "$TOTAL_SIZE_CURL" = "0" ] || [ "$TOTAL_SIZE_CURL" = "0b" ]; then
|
||||
TOTAL_SIZE_CURL="$TOTAL_SIZE"
|
||||
fi
|
||||
|
||||
DETAIL="($XFERD / $TOTAL_SIZE_CURL) @ $SPEED — $TIME_LEFT remaining"
|
||||
LABEL=$(format_label "$BASENAME" "$DETAIL")
|
||||
|
||||
$QDBUS_CMD "$PROGRESS_SERVICE" "$PROGRESS_OBJECT" Set "" value "$PCT" 2>/dev/null || true
|
||||
$QDBUS_CMD "$PROGRESS_SERVICE" "$PROGRESS_OBJECT" setLabelText "$LABEL" 2>/dev/null || true
|
||||
fi
|
||||
done < "$FIFO"
|
||||
else
|
||||
# No progress dialog — drain the FIFO in the background so curl isn't blocked
|
||||
cat "$FIFO" >/dev/null &
|
||||
fi
|
||||
|
||||
wait "$CURL_PID"
|
||||
|
||||
# Close the progress dialog
|
||||
if [ -n "$PROGRESS_SERVICE" ] && [ -n "$PROGRESS_OBJECT" ]; then
|
||||
$QDBUS_CMD "$PROGRESS_SERVICE" "$PROGRESS_OBJECT" close 2>/dev/null || true
|
||||
fi
|
||||
|
||||
rm -f "$FIFO"
|
||||
|
||||
# ==========================================
|
||||
# Parse response
|
||||
# ==========================================
|
||||
HTTP_CODE=$(tail -n1 "$RESPONSE_TMP")
|
||||
BODY=$(sed '$d' "$RESPONSE_TMP")
|
||||
rm -f "$RESPONSE_TMP"
|
||||
|
||||
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
|
||||
# Parse the direct file URL from the JSON response.
|
||||
# Expects a response containing a "file_url" field.
|
||||
URL=$(echo "$BODY" | grep -oP '"file_url"\s*:\s*"\K[^"]+')
|
||||
|
||||
# Fallback: if no file_url field is found, copy the whole body
|
||||
if [ -z "$URL" ]; then
|
||||
URL="$BODY"
|
||||
fi
|
||||
|
||||
echo -n "$URL" | $COPY_CMD
|
||||
show_notification "Upload Successful" \
|
||||
"Link copied to clipboard!<br><br><a href=\"$URL\">$URL</a>" \
|
||||
"cloud-upload-symbolic"
|
||||
else
|
||||
show_notification "Upload Failed" "HTTP Error $HTTP_CODE\n$BODY" "dialog-error"
|
||||
exit 1
|
||||
fi
|
||||
#!/usr/bin/env bash
|
||||
# Pass execution directly to the Python GUI engine
|
||||
exec kde-uploader-gui --upload="$1"
|
||||
|
||||
Reference in New Issue
Block a user