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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

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()

108
install.sh Normal file → Executable file
View File

@@ -9,22 +9,110 @@ LEGACY_SERVICE_MENU_DIR="$HOME/.local/share/kservices5/ServiceMenus"
# Create necessary directories # Create necessary directories
mkdir -p "$INSTALL_DIR" "$APP_DIR" "$SERVICE_MENU_DIR" "$LEGACY_SERVICE_MENU_DIR" 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" cp uploader.sh "$INSTALL_DIR/kde-uploader"
chmod +x "$INSTALL_DIR/kde-uploader" chmod +x "$INSTALL_DIR/kde-uploader"
# 2. Install standard application entry (for Spectacle) cp gui.py "$INSTALL_DIR/kde-uploader-gui"
sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader|g" kde-uploader.desktop > "$APP_DIR/kde-uploader.desktop" chmod +x "$INSTALL_DIR/kde-uploader-gui"
chmod +x "$APP_DIR/kde-uploader.desktop"
update-desktop-database "$APP_DIR" &> /dev/null
# 3. Install KDE Service Menu entry (for Dolphin) mkdir -p "$HOME/.local/share/icons/hicolor/scalable/apps"
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/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" 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" 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 "Installation complete!"
echo "The script has been installed to $INSTALL_DIR/kde-uploader" 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 "The GUI has been installed to $INSTALL_DIR/kde-uploader-gui"
echo "In Spectacle, you should find 'Upload to Custom API' in the Export or Share menu." 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
View 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__

View File

@@ -9,4 +9,4 @@ X-KDE-Submenu=Upload
[Desktop Action upload_custom_api] [Desktop Action upload_custom_api]
Name=Upload to f0ckm Name=Upload to f0ckm
Icon=cloud-upload-symbolic Icon=cloud-upload-symbolic
Exec=__EXEC_PATH__ "%u" Exec=__EXEC_PATH__ "%f"

View File

@@ -1,16 +1,16 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=f0ckm Uploader Name=f0ckm Uploader (Internal)
Comment=Upload to f0ckm Comment=Upload to f0ckm (Backend registration)
Exec=__EXEC_PATH__ %F Exec=__EXEC_PATH__
Icon=network-server Icon=f0ckm-uploader
Terminal=false Terminal=false
Categories=Utility;Network; 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; 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 InitialPreference=0
[Desktop Action upload] [Desktop Action upload]
Name=Upload to Custom API Name=Upload to Custom API
Exec=__EXEC_PATH__ %F Exec=__EXEC_PATH__
Icon=network-server Icon=f0ckm-uploader

View File

@@ -1,93 +1,3 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Pass execution directly to the Python GUI engine
# ── 0. Load Environment Variables from .env ───────────────────────────── exec kde-uploader-gui --spectacle "$@"
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

View File

@@ -6,17 +6,27 @@ APP_DIR="$HOME/.local/share/applications"
SERVICE_MENU_DIR="$HOME/.local/share/kio/servicemenus" SERVICE_MENU_DIR="$HOME/.local/share/kio/servicemenus"
LEGACY_SERVICE_MENU_DIR="$HOME/.local/share/kservices5/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 if [ -f "$INSTALL_DIR/kde-uploader" ]; then
rm "$INSTALL_DIR/kde-uploader" rm "$INSTALL_DIR/kde-uploader"
echo "Removed executable from $INSTALL_DIR" echo "Removed uploader executable from $INSTALL_DIR"
fi 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 if [ -f "$APP_DIR/kde-uploader.desktop" ]; then
rm "$APP_DIR/kde-uploader.desktop" rm "$APP_DIR/kde-uploader.desktop"
echo "Removed application entry from $APP_DIR" echo "Removed application entry from $APP_DIR"
fi 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 update-desktop-database "$APP_DIR" &> /dev/null
# 3. Remove the modern KDE Service Menu entry # 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" echo "Removed legacy service menu entry from $LEGACY_SERVICE_MENU_DIR"
fi 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."

View File

@@ -1,193 +1,3 @@
#!/bin/bash #!/usr/bin/env bash
# Pass execution directly to the Python GUI engine
# ========================================== exec kde-uploader-gui --upload="$1"
# 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