fixing progress

This commit is contained in:
2026-08-11 23:21:25 +02:00
parent e4b119e0b8
commit 0ca65ea473

252
gui.py
View File

@@ -11,6 +11,8 @@ import mimetypes
import urllib.parse import urllib.parse
import re import re
import shutil import shutil
import socket
import ssl
def normalize_file_path(path_str: str) -> str: def normalize_file_path(path_str: str) -> str:
if not path_str: if not path_str:
@@ -587,7 +589,7 @@ class DraggableImageLabel(QLabel):
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_pos = event.pos() self.drag_start_pos = event.position().toPoint()
super().mousePressEvent(event) super().mousePressEvent(event)
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
@@ -595,7 +597,7 @@ class DraggableImageLabel(QLabel):
return return
if self.drag_start_pos is None: if self.drag_start_pos is None:
return return
if (event.pos() - self.drag_start_pos).manhattanLength() < QApplication.startDragDistance(): if (event.position().toPoint() - self.drag_start_pos).manhattanLength() < QApplication.startDragDistance():
return return
if self.file_path and os.path.exists(self.file_path): if self.file_path and os.path.exists(self.file_path):
@@ -1012,7 +1014,40 @@ def play_success_sound(audio_path=None, volume=None):
except Exception: except Exception:
pass pass
class CurlUploadThread(QThread): class StreamingMultipartEncoder:
def __init__(self, fields, file_key, file_path):
self.boundary = f"----WebKitFormBoundary{time.time_ns():x}"
self.file_path = file_path
self.file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
self.fields = fields
self.file_key = file_key
body_parts = []
for key, val in fields.items():
if val is not None and str(val) != "":
body_parts.append(f"--{self.boundary}\r\nContent-Disposition: form-data; name=\"{key}\"\r\n\r\n{val}\r\n".encode('utf-8'))
filename = os.path.basename(file_path)
mime_type, _ = mimetypes.guess_type(file_path)
if not mime_type:
ext = os.path.splitext(file_path)[1].lower()
if ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']:
mime_type = f"image/{ext.lstrip('.')}"
if mime_type == "image/jpg": mime_type = "image/jpeg"
elif ext in ['.mp4', '.webm', '.mkv', '.mov', '.avi']:
mime_type = f"video/{ext.lstrip('.')}"
else:
mime_type = "image/png"
file_header = f"--{self.boundary}\r\nContent-Disposition: form-data; name=\"{self.file_key}\"; filename=\"{filename}\"\r\nContent-Type: {mime_type}\r\n\r\n".encode('utf-8')
file_footer = f"\r\n--{self.boundary}--\r\n".encode('utf-8')
self.header_bytes = b"".join(body_parts) + file_header
self.footer_bytes = file_footer
self.total_size = len(self.header_bytes) + self.file_size + len(self.footer_bytes)
self.content_type = f"multipart/form-data; boundary={self.boundary}"
class StreamingUploadThread(QThread):
progress = Signal(int, int) # bytes_sent, bytes_total progress = Signal(int, int) # bytes_sent, bytes_total
finished = Signal(bool, str) # success, response_or_error_msg finished = Signal(bool, str) # success, response_or_error_msg
@@ -1025,93 +1060,146 @@ class CurlUploadThread(QThread):
self.tags = tags self.tags = tags
self.visibility = visibility self.visibility = visibility
self.is_oc = is_oc self.is_oc = is_oc
self.process = None
self.file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
self.aborted = False self.aborted = False
self.sock = None
def abort(self): def abort(self):
self.aborted = True self.aborted = True
if self.process: if self.sock:
try: try:
self.process.kill() self.sock.close()
except Exception: except Exception:
pass pass
def run(self): def run(self):
mime_type, _ = mimetypes.guess_type(self.file_path) fields = {
if not mime_type: "rating": self.rating,
ext = os.path.splitext(self.file_path)[1].lower() "tags": self.tags,
if ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']: "visibility": self.visibility,
mime_type = f"image/{ext.lstrip('.')}" "is_oc": self.is_oc
if mime_type == "image/jpg": }
mime_type = "image/jpeg"
elif ext in ['.mp4', '.webm', '.mkv', '.mov', '.avi']:
mime_type = f"video/{ext.lstrip('.')}"
else:
mime_type = "image/png"
file_param = f"file=@{self.file_path}"
if mime_type:
file_param += f";type={mime_type}"
cmd = [
"curl", "-#",
"-F", file_param
]
if self.api_key:
cmd.extend(["-H", f"X-Api-Key: {self.api_key}"])
if self.rating:
cmd.extend(["-F", f"rating={self.rating}"])
if self.tags:
cmd.extend(["-F", f"tags={self.tags}"])
if self.visibility != "":
cmd.extend(["-F", f"visibility={self.visibility}"])
if self.is_oc != "0":
cmd.extend(["-F", f"is_oc={self.is_oc}"])
cmd.append(self.api_url)
try: try:
self.process = subprocess.Popen( encoder = StreamingMultipartEncoder(fields, "file", self.file_path)
cmd, parsed_url = urllib.parse.urlparse(self.api_url)
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
buffer = "" host = parsed_url.hostname
while True: port = parsed_url.port or (443 if parsed_url.scheme == "https" else 80)
char = self.process.stderr.read(1) path = parsed_url.path or "/"
if not char and self.process.poll() is not None: if parsed_url.query:
path += "?" + parsed_url.query
raw_sock = socket.create_connection((host, port), timeout=30)
if parsed_url.scheme == "https":
ctx = ssl.create_default_context()
self.sock = ctx.wrap_socket(raw_sock, server_hostname=host)
else:
self.sock = raw_sock
req_headers = (
f"POST {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"User-Agent: f0ckm-uploader/2.0\r\n"
f"X-Api-Key: {self.api_key}\r\n"
f"Content-Type: {encoder.content_type}\r\n"
f"Content-Length: {encoder.total_size}\r\n"
f"Connection: close\r\n\r\n"
).encode('utf-8')
self.sock.sendall(req_headers)
total_sent = 0
self.sock.sendall(encoder.header_bytes)
total_sent += len(encoder.header_bytes)
self.progress.emit(total_sent, encoder.total_size)
chunk_size = 32768
with open(self.file_path, "rb") as f:
while True:
if self.aborted:
self.finished.emit(False, "ABORTED")
return
chunk = f.read(chunk_size)
if not chunk:
break
self.sock.sendall(chunk)
total_sent += len(chunk)
pct = min(99, int((total_sent / encoder.total_size) * 100))
bytes_sent_calc = int(encoder.total_size * (pct / 100.0))
self.progress.emit(bytes_sent_calc, encoder.total_size)
time.sleep(0.005)
self.sock.sendall(encoder.footer_bytes)
total_sent += len(encoder.footer_bytes)
self.progress.emit(encoder.total_size, encoder.total_size)
response_data = bytearray()
while not self.aborted:
data = self.sock.recv(4096)
if not data:
break break
if char in ("\r", "\n"): response_data.extend(data)
matches = re.findall(r'(\d+(?:\.\d+)?)\s*%', buffer)
if matches:
try:
pct = float(matches[-1])
bytes_sent = int(self.file_size * (pct / 100.0))
self.progress.emit(bytes_sent, self.file_size)
except Exception:
pass
buffer = ""
else:
buffer += char
stdout_data, stderr_data = self.process.communicate()
exit_code = self.process.returncode
if self.aborted: if self.aborted:
self.finished.emit(False, "ABORTED") self.finished.emit(False, "ABORTED")
elif exit_code == 0: return
self.finished.emit(True, stdout_data)
parts = response_data.split(b"\r\n\r\n", 1)
headers_str = parts[0].decode('utf-8', errors='replace') if parts else ""
raw_body = parts[1].decode('utf-8', errors='replace') if len(parts) > 1 else response_data.decode('utf-8', errors='replace')
status_line = headers_str.split("\r\n")[0] if headers_str else ""
# De-chunk HTTP/1.1 response body if Transfer-Encoding: chunked or chunk-prefixed
body = raw_body
if "transfer-encoding: chunked" in headers_str.lower() or re.match(r'^[0-9a-fA-F]+\r\n', raw_body.lstrip()):
decoded_chunks = []
rest = raw_body.lstrip()
try:
while rest:
lines = rest.split("\r\n", 1)
if len(lines) < 2:
break
hex_str = lines[0].strip().split(";")[0]
try:
chunk_len = int(hex_str, 16)
except ValueError:
break
if chunk_len == 0:
break
chunk_content = lines[1][:chunk_len]
decoded_chunks.append(chunk_content)
rest = lines[1][chunk_len:]
if rest.startswith("\r\n"):
rest = rest[2:]
if decoded_chunks:
body = "".join(decoded_chunks)
except Exception:
pass
# Extra safety fallback: extract JSON substring if there's any hex prefix left
if not body.strip().startswith("{") and "{" in body and "}" in body:
match = re.search(r'\{.*\}', body, re.DOTALL)
if match:
body = match.group(0)
if "200" in status_line or "201" in status_line:
self.finished.emit(True, body)
else: else:
self.finished.emit(False, stderr_data or f"curl exited with code {exit_code}") self.finished.emit(False, body or status_line)
except Exception as e: except Exception as e:
if self.aborted: if self.aborted:
self.finished.emit(False, "ABORTED") self.finished.emit(False, "ABORTED")
else: else:
self.finished.emit(False, str(e)) self.finished.emit(False, str(e))
finally:
if self.sock:
try:
self.sock.close()
except Exception:
pass
# ========================================== # ==========================================
# System Tray Application Manager # System Tray Application Manager
@@ -1238,6 +1326,12 @@ class SystemTrayApp(QObject):
if os.path.exists(save_path) and os.path.getsize(save_path) > 0: if os.path.exists(save_path) and os.path.getsize(save_path) > 0:
self.upload_file_direct(save_path) self.upload_file_direct(save_path)
def on_abort_upload(self):
if self.current_reply and self.current_reply.isRunning():
self.current_reply.abort()
elif hasattr(self, "upload_thread") and self.upload_thread:
self.upload_thread.abort()
def upload_file_direct(self, file_path): def upload_file_direct(self, file_path):
file_path = normalize_file_path(file_path) file_path = normalize_file_path(file_path)
if not file_path or not os.path.exists(file_path): if not file_path or not os.path.exists(file_path):
@@ -1247,10 +1341,13 @@ class SystemTrayApp(QObject):
config = get_env_config() config = get_env_config()
api_url = config.get("api_url", "") api_url = config.get("api_url", "")
api_key = config.get("api_key", "") api_key = config.get("api_key", "")
if not api_url or not api_key:
self.show_message("Upload Error", "API URL and API Key must be set in Settings.", QSystemTrayIcon.Critical)
return
rating = config.get("default_rating", "") rating = config.get("default_rating", "")
tags = config.get("default_tags", "") tags = config.get("default_tags", "")
if tags: if tags:
# Format tags as clean comma-separated string ("tag1, tag2") for f0ckm API tagsRaw.split(',') compatibility
tag_list = [t.strip() for t in re.split(r'[,]+', tags) if t.strip()] tag_list = [t.strip() for t in re.split(r'[,]+', tags) if t.strip()]
tags = ", ".join(tag_list) tags = ", ".join(tag_list)
@@ -1264,13 +1361,28 @@ class SystemTrayApp(QObject):
self.tray.setIcon(create_digit_icon(0)) self.tray.setIcon(create_digit_icon(0))
self.tray.setToolTip(f"Uploading {filename}...\n0% (0 B)") self.tray.setToolTip(f"Uploading {filename}...\n0% (0 B)")
self.upload_thread = CurlUploadThread(api_url, api_key, file_path, rating, tags, visibility, is_oc) self.upload_thread = StreamingUploadThread(api_url, api_key, file_path, rating, tags, visibility, is_oc)
self.upload_thread.progress.connect(self.on_upload_progress) self.upload_thread.progress.connect(self.on_upload_progress)
self.upload_thread.finished.connect(lambda ok, resp: self.on_upload_finished(ok, resp, file_path)) self.upload_thread.finished.connect(lambda ok, resp: self.on_upload_finished(ok, resp, file_path))
self.upload_thread.start() self.upload_thread.start()
self.abort_action.setEnabled(True) self.abort_action.setEnabled(True)
def _on_qt_reply_finished(self, reply, file_path):
self.current_reply = None
err = reply.error()
if err == QNetworkReply.NetworkError.NoError:
resp_bytes = reply.readAll().data()
resp_str = resp_bytes.decode('utf-8', errors='replace')
self.on_upload_finished(True, resp_str, file_path)
else:
if err == QNetworkReply.NetworkError.OperationCanceledError:
self.on_upload_finished(False, "ABORTED", file_path)
else:
resp_bytes = reply.readAll().data()
resp_str = resp_bytes.decode('utf-8', errors='replace') if resp_bytes else reply.errorString()
self.on_upload_finished(False, resp_str, file_path)
def on_upload_progress(self, bytes_sent, bytes_total): def on_upload_progress(self, bytes_sent, bytes_total):
if bytes_total > 0: if bytes_total > 0:
pct = min(100, max(0, int((bytes_sent / bytes_total) * 100))) pct = min(100, max(0, int((bytes_sent / bytes_total) * 100)))