Skip to content

Commit 6cb7094

Browse files
committed
feat: worker pool, auto-update check (Phase 2)
Worker Pool (opencut/workers.py): - ThreadPoolExecutor-based WorkerPool with job_id tracking - JobPriority enum: CRITICAL/HIGH/NORMAL/LOW/BACKGROUND - submit(), cancel(), is_running(), active_count(), shutdown() - Thread-safe singleton via get_pool()/shutdown_pool() - async_job decorator now uses pool instead of raw Thread spawning - Futures stored in job dict as _future for tracking - Pool shutdown registered via atexit on server exit Auto-Update Check: - GET /system/update-check — fetches latest GitHub release tag, compares with __version__, 1-hour cache, 5s timeout, fails silently - CEP panel: one-time check on health connect, toast if update available - UXP panel: same check in initApp() after connection established
1 parent 9ec409f commit 6cb7094

6 files changed

Lines changed: 174 additions & 4 deletions

File tree

extension/com.opencut.panel/client/main.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
var pollTimer = null;
2424
var healthTimer = null;
2525
var csrfToken = "";
26+
var _updateCheckDone = false;
2627
var lastXmlPath = "";
2728
var lastCaptionPath = "";
2829
var lastOverlayPath = "";
@@ -1088,6 +1089,15 @@
10881089
el.backendPort.textContent = BACKEND.replace("http://127.0.0.1:", "Port ");
10891090
updateButtons();
10901091
loadCapabilities();
1092+
// One-time update check after server connects
1093+
if (!_updateCheckDone) {
1094+
_updateCheckDone = true;
1095+
api("GET", "/system/update-check", null, function (uerr, udata) {
1096+
if (!uerr && udata && udata.update_available) {
1097+
showToast("OpenCut v" + udata.latest_version + " available \u2014 visit GitHub to update", "info");
1098+
}
1099+
});
1100+
}
10911101
return;
10921102
}
10931103
if (connected && el.serverStatusBanner) {

extension/com.opencut.uxp/main.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1554,6 +1554,16 @@ async function initApp() {
15541554
await BackendClient.fetchCsrf();
15551555
await loadLlmSettings();
15561556
UIController.showToast("OpenCut backend connected.", "success");
1557+
1558+
// One-time update check
1559+
const ur = await BackendClient.get("/system/update-check");
1560+
if (ur.ok && ur.data && ur.data.update_available) {
1561+
UIController.showToast(
1562+
`OpenCut v${ur.data.latest_version} available \u2014 visit GitHub to update`,
1563+
"info",
1564+
6000
1565+
);
1566+
}
15571567
}
15581568

15591569
// Periodic health checks

opencut/jobs.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,11 @@ def _process():
231231
_update_job(job_id, status="error", error=str(e),
232232
message=f"Error: {e}")
233233

234-
import threading as _t
235-
thread = _t.Thread(target=_process, daemon=True)
236-
thread.start()
234+
from opencut.workers import get_pool
235+
future = get_pool().submit(job_id, _process)
237236
with job_lock:
238237
if job_id in jobs:
239-
jobs[job_id]["_thread"] = thread
238+
jobs[job_id]["_future"] = future
240239
return jsonify({"job_id": job_id})
241240
return wrapper
242241
return decorator

opencut/routes/system.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,3 +1224,56 @@ def llm_test():
12241224
})
12251225
except Exception as e:
12261226
return jsonify({"success": False, "error": str(e)}), 500
1227+
1228+
1229+
# ---------------------------------------------------------------------------
1230+
# Update Check (GitHub releases, 1-hour cache)
1231+
# ---------------------------------------------------------------------------
1232+
_update_cache = {"data": None, "ts": 0}
1233+
_update_cache_lock = threading.Lock()
1234+
_UPDATE_CACHE_TTL = 3600 # 1 hour
1235+
1236+
1237+
@system_bp.route("/system/update-check", methods=["GET"])
1238+
def check_for_update():
1239+
"""Check GitHub for a newer release. Cached for 1 hour."""
1240+
import json
1241+
import urllib.request
1242+
1243+
now = time.time()
1244+
with _update_cache_lock:
1245+
if _update_cache["data"] is not None and (now - _update_cache["ts"]) < _UPDATE_CACHE_TTL:
1246+
return jsonify(_update_cache["data"])
1247+
1248+
current = __version__
1249+
result = {
1250+
"current_version": current,
1251+
"latest_version": current,
1252+
"update_available": False,
1253+
"release_url": "https://github.com/SysAdminDoc/OpenCut/releases",
1254+
}
1255+
1256+
try:
1257+
url = "https://api.github.com/repos/SysAdminDoc/OpenCut/releases/latest"
1258+
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github.v3+json", "User-Agent": "OpenCut"})
1259+
with urllib.request.urlopen(req, timeout=5) as resp:
1260+
data = json.loads(resp.read().decode("utf-8"))
1261+
1262+
tag = data.get("tag_name", "").lstrip("vV")
1263+
html_url = data.get("html_url", result["release_url"])
1264+
1265+
if tag:
1266+
result["latest_version"] = tag
1267+
result["release_url"] = html_url
1268+
current_parts = tuple(int(x) for x in current.split("."))
1269+
latest_parts = tuple(int(x) for x in tag.split("."))
1270+
result["update_available"] = latest_parts > current_parts
1271+
except Exception as exc:
1272+
logger.debug("Update check failed: %s", exc)
1273+
result["error"] = "offline"
1274+
1275+
with _update_cache_lock:
1276+
_update_cache["data"] = result
1277+
_update_cache["ts"] = now
1278+
1279+
return jsonify(result)

opencut/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,8 @@ def run_server(host="127.0.0.1", port=5679, debug=False):
560560
# Register cleanup on normal exit
561561
import atexit
562562
atexit.register(_remove_pid)
563+
from opencut.workers import shutdown_pool
564+
atexit.register(shutdown_pool)
563565

564566
print("")
565567
print(" OpenCut Backend Server v1.6.0")

opencut/workers.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""
2+
OpenCut Worker Pool
3+
4+
Priority-based thread pool for job execution. Replaces raw thread spawning
5+
with bounded concurrency and job priority support.
6+
"""
7+
8+
import logging
9+
import threading
10+
from concurrent.futures import Future, ThreadPoolExecutor
11+
from enum import IntEnum
12+
13+
logger = logging.getLogger("opencut")
14+
15+
16+
class JobPriority(IntEnum):
17+
"""Job priority levels. Lower value = higher priority."""
18+
CRITICAL = 0 # System operations (health, model management)
19+
HIGH = 10 # Quick CPU operations (silence detect, beat markers)
20+
NORMAL = 50 # Standard operations (transcribe, denoise, export)
21+
LOW = 100 # Heavy AI operations (upscale, style transfer, music gen)
22+
BACKGROUND = 200 # Batch/indexing operations
23+
24+
25+
class WorkerPool:
26+
"""Thread pool with priority queue for OpenCut job execution."""
27+
28+
def __init__(self, max_workers=10):
29+
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="oc-worker")
30+
self._futures: dict[str, Future] = {} # job_id -> Future
31+
self._lock = threading.Lock()
32+
self._shutdown = False
33+
logger.info("WorkerPool initialized with %d max workers", max_workers)
34+
35+
def submit(self, job_id: str, fn, *args, priority=JobPriority.NORMAL, **kwargs) -> Future:
36+
"""Submit a job function to the pool. Returns a Future."""
37+
if self._shutdown:
38+
raise RuntimeError("WorkerPool is shut down")
39+
future = self._executor.submit(fn, *args, **kwargs)
40+
with self._lock:
41+
self._futures[job_id] = future
42+
future.add_done_callback(lambda f: self._on_done(job_id, f))
43+
return future
44+
45+
def cancel(self, job_id: str) -> bool:
46+
"""Cancel a pending job. Returns True if cancelled."""
47+
with self._lock:
48+
future = self._futures.get(job_id)
49+
if future:
50+
return future.cancel()
51+
return False
52+
53+
def is_running(self, job_id: str) -> bool:
54+
"""Check if a job is currently running."""
55+
with self._lock:
56+
future = self._futures.get(job_id)
57+
return future is not None and future.running()
58+
59+
def _on_done(self, job_id: str, future: Future):
60+
"""Cleanup callback when job completes."""
61+
with self._lock:
62+
self._futures.pop(job_id, None)
63+
64+
def active_count(self) -> int:
65+
"""Number of currently running/pending jobs."""
66+
with self._lock:
67+
return len(self._futures)
68+
69+
def shutdown(self, wait=True):
70+
"""Shut down the pool. Called on server exit."""
71+
self._shutdown = True
72+
self._executor.shutdown(wait=wait)
73+
logger.info("WorkerPool shut down")
74+
75+
76+
# Module-level singleton
77+
_pool: WorkerPool | None = None
78+
_pool_lock = threading.Lock()
79+
80+
81+
def get_pool(max_workers=10) -> WorkerPool:
82+
"""Get or create the global WorkerPool singleton."""
83+
global _pool
84+
if _pool is None:
85+
with _pool_lock:
86+
if _pool is None:
87+
_pool = WorkerPool(max_workers=max_workers)
88+
return _pool
89+
90+
91+
def shutdown_pool(wait=True):
92+
"""Shut down the global pool. Called on server exit."""
93+
global _pool
94+
if _pool is not None:
95+
_pool.shutdown(wait=wait)
96+
_pool = None

0 commit comments

Comments
 (0)