davinci-resolve-mcp 2.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +85 -0
- package/CHANGELOG.md +802 -0
- package/CLAUDE.md +15 -0
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/SECURITY.md +53 -0
- package/bin/davinci-resolve-mcp.mjs +376 -0
- package/docs/README.md +56 -0
- package/docs/SKILL.md +1145 -0
- package/docs/authoring/fuse-dctl-authoring.md +242 -0
- package/docs/authoring/script-plugin-authoring.md +195 -0
- package/docs/contributing.md +82 -0
- package/docs/guides/color-decision-guide.md +387 -0
- package/docs/guides/editorial-decision-guide.md +136 -0
- package/docs/guides/media-analysis-guide.md +615 -0
- package/docs/guides/multicam-setup-guide.md +138 -0
- package/docs/install.md +198 -0
- package/docs/integrations/workflow-integrations.md +120 -0
- package/docs/kernels/README.md +28 -0
- package/docs/kernels/audio-fairlight-kernel.md +86 -0
- package/docs/kernels/color-grade-kernel.md +103 -0
- package/docs/kernels/extension-authoring-kernel.md +101 -0
- package/docs/kernels/fusion-composition-kernel.md +91 -0
- package/docs/kernels/media-pool-ingest-kernel.md +147 -0
- package/docs/kernels/project-lifecycle-kernel.md +120 -0
- package/docs/kernels/render-deliver-kernel.md +92 -0
- package/docs/kernels/review-annotation-kernel.md +110 -0
- package/docs/kernels/timeline-conform-interchange-kernel.md +99 -0
- package/docs/kernels/timeline-edit-kernel.md +189 -0
- package/docs/notes/codec-plugin-notes.md +136 -0
- package/docs/notes/dctl-notes.md +234 -0
- package/docs/notes/fusion-template-notes.md +136 -0
- package/docs/notes/lut-notes.md +136 -0
- package/docs/notes/openfx-notes.md +120 -0
- package/docs/process/release-process.md +152 -0
- package/docs/reference/api-coverage.md +488 -0
- package/docs/reference/resolve_scripting_api.txt +1012 -0
- package/examples/README.md +53 -0
- package/examples/markers/README.md +81 -0
- package/examples/media/README.md +94 -0
- package/examples/timeline/README.md +98 -0
- package/install.py +1196 -0
- package/package.json +52 -0
- package/scripts/audit_api_parity.py +275 -0
- package/scripts/live_media_analysis_polish_probe.py +65 -0
- package/src/__init__.py +3 -0
- package/src/analysis_dashboard.py +4936 -0
- package/src/control_panel.py +13 -0
- package/src/granular/__init__.py +17 -0
- package/src/granular/common.py +727 -0
- package/src/granular/folder.py +287 -0
- package/src/granular/gallery.py +306 -0
- package/src/granular/graph.py +309 -0
- package/src/granular/media_pool.py +679 -0
- package/src/granular/media_pool_item.py +852 -0
- package/src/granular/media_storage.py +179 -0
- package/src/granular/project.py +1594 -0
- package/src/granular/resolve_control.py +521 -0
- package/src/granular/timeline.py +1074 -0
- package/src/granular/timeline_item.py +2251 -0
- package/src/resolve_mcp_server.py +43 -0
- package/src/server.py +15691 -0
- package/src/utils/__init__.py +3 -0
- package/src/utils/app_control.py +319 -0
- package/src/utils/audio_fairlight_live_probe.py +263 -0
- package/src/utils/cdl.py +20 -0
- package/src/utils/cloud_operations.py +192 -0
- package/src/utils/color_grade_live_probe.py +444 -0
- package/src/utils/dctl_templates.py +368 -0
- package/src/utils/extension_authoring_live_probe.py +292 -0
- package/src/utils/fuse_templates.py +1968 -0
- package/src/utils/fusion_composition_live_probe.py +284 -0
- package/src/utils/layout_presets.py +333 -0
- package/src/utils/mcp_stdio.py +32 -0
- package/src/utils/media_analysis.py +3618 -0
- package/src/utils/media_analysis_jobs.py +796 -0
- package/src/utils/media_pool_ingest_live_probe.py +592 -0
- package/src/utils/multicam.py +393 -0
- package/src/utils/object_inspection.py +287 -0
- package/src/utils/platform.py +157 -0
- package/src/utils/project_lifecycle_live_probe.py +376 -0
- package/src/utils/project_properties.py +601 -0
- package/src/utils/render_deliver_live_probe.py +384 -0
- package/src/utils/resolve_connection.py +77 -0
- package/src/utils/review_annotation_live_probe.py +352 -0
- package/src/utils/script_templates.py +1193 -0
- package/src/utils/sync_detection.py +887 -0
- package/src/utils/timeline_conform_live_probe.py +280 -0
- package/src/utils/timeline_kernel_live_probe.py +1091 -0
- package/src/utils/timeline_kernel_probe.py +185 -0
- package/src/utils/timeline_title_text.py +87 -0
- package/src/utils/update_check.py +610 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Best-effort update checks for the DaVinci Resolve MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, Mapping, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_REPO = "samuelgursky/davinci-resolve-mcp"
|
|
18
|
+
DEFAULT_INTERVAL_HOURS = 24.0
|
|
19
|
+
DEFAULT_TIMEOUT_SECONDS = 3.0
|
|
20
|
+
DEFAULT_SNOOZE_HOURS = 24.0
|
|
21
|
+
|
|
22
|
+
ENV_ENABLED = "DAVINCI_RESOLVE_MCP_UPDATE_CHECK"
|
|
23
|
+
ENV_INTERVAL_HOURS = "DAVINCI_RESOLVE_MCP_UPDATE_INTERVAL_HOURS"
|
|
24
|
+
ENV_MODE = "DAVINCI_RESOLVE_MCP_UPDATE_MODE"
|
|
25
|
+
ENV_REPO = "DAVINCI_RESOLVE_MCP_UPDATE_REPO"
|
|
26
|
+
ENV_SNOOZE_HOURS = "DAVINCI_RESOLVE_MCP_UPDATE_SNOOZE_HOURS"
|
|
27
|
+
ENV_URL = "DAVINCI_RESOLVE_MCP_UPDATE_URL"
|
|
28
|
+
ENV_STATE_PATH = "DAVINCI_RESOLVE_MCP_UPDATE_STATE"
|
|
29
|
+
|
|
30
|
+
_FALSE_VALUES = {"0", "false", "no", "off", "disabled"}
|
|
31
|
+
_TRUE_VALUES = {"1", "true", "yes", "on", "enabled"}
|
|
32
|
+
_UPDATE_MODES = {"prompt", "auto", "notify", "never"}
|
|
33
|
+
_SUCCESS_STATUSES = {"up_to_date", "update_available", "current_ahead"}
|
|
34
|
+
_PERSISTENT_STATE_KEYS = (
|
|
35
|
+
"update_mode",
|
|
36
|
+
"ignored_version",
|
|
37
|
+
"ignored_tag",
|
|
38
|
+
"ignored_at",
|
|
39
|
+
"ignored_at_iso",
|
|
40
|
+
"snooze_until",
|
|
41
|
+
"snooze_until_iso",
|
|
42
|
+
)
|
|
43
|
+
_started_lock = threading.Lock()
|
|
44
|
+
_started = False
|
|
45
|
+
_cached_lock = threading.Lock()
|
|
46
|
+
_cached_status: Dict[str, Any] = {"status": "unknown"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def update_check_enabled(env: Optional[Mapping[str, str]] = None) -> bool:
|
|
50
|
+
"""Return whether startup update checks are enabled."""
|
|
51
|
+
values = os.environ if env is None else env
|
|
52
|
+
raw = str(values.get(ENV_ENABLED, "1")).strip().lower()
|
|
53
|
+
return raw not in _FALSE_VALUES and _normalize_update_mode(values.get(ENV_MODE)) != "never"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_update_mode(
|
|
57
|
+
project_dir: Optional[os.PathLike[str] | str] = None,
|
|
58
|
+
env: Optional[Mapping[str, str]] = None,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Return the configured update policy mode.
|
|
61
|
+
|
|
62
|
+
Modes:
|
|
63
|
+
- prompt: check and let human-facing callers prompt.
|
|
64
|
+
- auto: human-facing callers may apply a safe update automatically.
|
|
65
|
+
- notify: check and report only.
|
|
66
|
+
- never: do not check for updates.
|
|
67
|
+
"""
|
|
68
|
+
values = os.environ if env is None else env
|
|
69
|
+
if not update_check_enabled(values):
|
|
70
|
+
return "never"
|
|
71
|
+
|
|
72
|
+
env_mode = _normalize_update_mode(values.get(ENV_MODE))
|
|
73
|
+
if env_mode:
|
|
74
|
+
return env_mode
|
|
75
|
+
|
|
76
|
+
legacy_auto = str(values.get("DAVINCI_RESOLVE_MCP_AUTO_UPDATE", "")).strip().lower()
|
|
77
|
+
if legacy_auto in _TRUE_VALUES:
|
|
78
|
+
return "auto"
|
|
79
|
+
if legacy_auto in _FALSE_VALUES:
|
|
80
|
+
return "prompt"
|
|
81
|
+
|
|
82
|
+
if project_dir is not None:
|
|
83
|
+
state = _read_state(update_state_path(project_dir, values))
|
|
84
|
+
state_mode = _normalize_update_mode(state.get("update_mode"))
|
|
85
|
+
if state_mode:
|
|
86
|
+
return state_mode
|
|
87
|
+
return "prompt"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_version(value: Any) -> Tuple[int, ...]:
|
|
91
|
+
"""Parse a release tag or version string into comparable integer parts."""
|
|
92
|
+
match = re.search(r"\d+(?:\.\d+)*", str(value or ""))
|
|
93
|
+
if not match:
|
|
94
|
+
return ()
|
|
95
|
+
return tuple(int(part) for part in match.group(0).split("."))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def compare_versions(current: Any, latest: Any) -> Optional[int]:
|
|
99
|
+
"""Compare two version strings, returning -1, 0, 1, or None if unknown."""
|
|
100
|
+
left = parse_version(current)
|
|
101
|
+
right = parse_version(latest)
|
|
102
|
+
if not left or not right:
|
|
103
|
+
return None
|
|
104
|
+
width = max(len(left), len(right), 3)
|
|
105
|
+
left = left + (0,) * (width - len(left))
|
|
106
|
+
right = right + (0,) * (width - len(right))
|
|
107
|
+
if left < right:
|
|
108
|
+
return -1
|
|
109
|
+
if left > right:
|
|
110
|
+
return 1
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def update_state_path(
|
|
115
|
+
project_dir: os.PathLike[str] | str,
|
|
116
|
+
env: Optional[Mapping[str, str]] = None,
|
|
117
|
+
) -> Path:
|
|
118
|
+
"""Return the JSON state path used to throttle update checks."""
|
|
119
|
+
values = os.environ if env is None else env
|
|
120
|
+
override = values.get(ENV_STATE_PATH)
|
|
121
|
+
if override:
|
|
122
|
+
return Path(override).expanduser()
|
|
123
|
+
return Path(project_dir) / "logs" / "update-check.json"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_cached_update_status(
|
|
127
|
+
project_dir: os.PathLike[str] | str,
|
|
128
|
+
current_version: Optional[str] = None,
|
|
129
|
+
env: Optional[Mapping[str, str]] = None,
|
|
130
|
+
) -> Dict[str, Any]:
|
|
131
|
+
"""Return the last known update status without performing network I/O."""
|
|
132
|
+
with _cached_lock:
|
|
133
|
+
cached = dict(_cached_status)
|
|
134
|
+
if cached.get("status") != "unknown":
|
|
135
|
+
if current_version and "current_version" not in cached:
|
|
136
|
+
cached["current_version"] = current_version
|
|
137
|
+
return cached
|
|
138
|
+
|
|
139
|
+
state = _read_state(update_state_path(project_dir, env))
|
|
140
|
+
if state:
|
|
141
|
+
if current_version and "current_version" not in state:
|
|
142
|
+
state["current_version"] = current_version
|
|
143
|
+
if "update_mode" not in state:
|
|
144
|
+
state["update_mode"] = get_update_mode(project_dir, env)
|
|
145
|
+
return state
|
|
146
|
+
result = {"status": "unknown"}
|
|
147
|
+
if current_version:
|
|
148
|
+
result["current_version"] = current_version
|
|
149
|
+
result["update_mode"] = get_update_mode(project_dir, env)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def check_for_updates(
|
|
154
|
+
current_version: str,
|
|
155
|
+
project_dir: os.PathLike[str] | str,
|
|
156
|
+
*,
|
|
157
|
+
env: Optional[Mapping[str, str]] = None,
|
|
158
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
159
|
+
now: Optional[float] = None,
|
|
160
|
+
force: bool = False,
|
|
161
|
+
) -> Dict[str, Any]:
|
|
162
|
+
"""Check GitHub releases for a newer MCP version.
|
|
163
|
+
|
|
164
|
+
This function never installs or modifies code. Network failures are returned
|
|
165
|
+
as structured status so startup can continue normally.
|
|
166
|
+
"""
|
|
167
|
+
values = os.environ if env is None else env
|
|
168
|
+
checked_at = time.time() if now is None else float(now)
|
|
169
|
+
state_path = update_state_path(project_dir, values)
|
|
170
|
+
previous = _read_state(state_path)
|
|
171
|
+
update_mode = get_update_mode(project_dir, values)
|
|
172
|
+
|
|
173
|
+
if update_mode == "never":
|
|
174
|
+
result = {
|
|
175
|
+
"status": "disabled",
|
|
176
|
+
"current_version": current_version,
|
|
177
|
+
"update_mode": "never",
|
|
178
|
+
"checked_at": checked_at,
|
|
179
|
+
"checked_at_iso": _format_timestamp(checked_at),
|
|
180
|
+
}
|
|
181
|
+
result = _merge_persistent_state(previous, result)
|
|
182
|
+
if update_check_enabled(values):
|
|
183
|
+
_write_state(state_path, result)
|
|
184
|
+
_set_cached_status(result)
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
interval_seconds = _interval_seconds(values)
|
|
188
|
+
if (
|
|
189
|
+
not force
|
|
190
|
+
and previous
|
|
191
|
+
and checked_at - float(previous.get("checked_at", 0)) < interval_seconds
|
|
192
|
+
):
|
|
193
|
+
cached = dict(previous)
|
|
194
|
+
cached["cached"] = True
|
|
195
|
+
cached["update_mode"] = update_mode
|
|
196
|
+
cached["next_check_at"] = float(previous.get("checked_at", 0)) + interval_seconds
|
|
197
|
+
cached["next_check_at_iso"] = _format_timestamp(cached["next_check_at"])
|
|
198
|
+
_set_cached_status(cached)
|
|
199
|
+
return cached
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
release = _fetch_latest_release(values, timeout)
|
|
203
|
+
result = _result_from_release(current_version, release, checked_at, values)
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
result = {
|
|
206
|
+
"status": "error",
|
|
207
|
+
"current_version": current_version,
|
|
208
|
+
"checked_at": checked_at,
|
|
209
|
+
"checked_at_iso": _format_timestamp(checked_at),
|
|
210
|
+
"error": str(exc),
|
|
211
|
+
}
|
|
212
|
+
if previous and previous.get("status") in _SUCCESS_STATUSES:
|
|
213
|
+
result["last_success"] = {
|
|
214
|
+
key: previous.get(key)
|
|
215
|
+
for key in (
|
|
216
|
+
"status",
|
|
217
|
+
"latest_version",
|
|
218
|
+
"latest_tag",
|
|
219
|
+
"release_url",
|
|
220
|
+
"checked_at",
|
|
221
|
+
"checked_at_iso",
|
|
222
|
+
)
|
|
223
|
+
if key in previous
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
result["update_mode"] = update_mode
|
|
227
|
+
result = _merge_persistent_state(previous, result)
|
|
228
|
+
_write_state(state_path, result)
|
|
229
|
+
_set_cached_status(result)
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def start_background_update_check(
|
|
234
|
+
current_version: str,
|
|
235
|
+
project_dir: os.PathLike[str] | str,
|
|
236
|
+
logger: Any,
|
|
237
|
+
*,
|
|
238
|
+
env: Optional[Mapping[str, str]] = None,
|
|
239
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
240
|
+
) -> Optional[threading.Thread]:
|
|
241
|
+
"""Start a daemon thread that checks for updates without blocking stdio."""
|
|
242
|
+
global _started
|
|
243
|
+
values = os.environ if env is None else env
|
|
244
|
+
if get_update_mode(project_dir, values) == "never":
|
|
245
|
+
result = {
|
|
246
|
+
"status": "disabled",
|
|
247
|
+
"current_version": current_version,
|
|
248
|
+
"update_mode": "never",
|
|
249
|
+
"checked_at": time.time(),
|
|
250
|
+
}
|
|
251
|
+
_set_cached_status(result)
|
|
252
|
+
_log_result(logger, result)
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
with _started_lock:
|
|
256
|
+
if _started:
|
|
257
|
+
return None
|
|
258
|
+
_started = True
|
|
259
|
+
|
|
260
|
+
def worker() -> None:
|
|
261
|
+
result = check_for_updates(
|
|
262
|
+
current_version,
|
|
263
|
+
project_dir,
|
|
264
|
+
env=values,
|
|
265
|
+
timeout=timeout,
|
|
266
|
+
)
|
|
267
|
+
_log_result(logger, result)
|
|
268
|
+
|
|
269
|
+
thread = threading.Thread(
|
|
270
|
+
target=worker,
|
|
271
|
+
name="davinci-resolve-mcp-update-check",
|
|
272
|
+
daemon=True,
|
|
273
|
+
)
|
|
274
|
+
thread.start()
|
|
275
|
+
return thread
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def update_prompt_decision(
|
|
279
|
+
result: Mapping[str, Any],
|
|
280
|
+
*,
|
|
281
|
+
env: Optional[Mapping[str, str]] = None,
|
|
282
|
+
now: Optional[float] = None,
|
|
283
|
+
) -> Dict[str, Any]:
|
|
284
|
+
"""Return whether a human-facing caller should prompt, auto-update, or skip."""
|
|
285
|
+
values = os.environ if env is None else env
|
|
286
|
+
timestamp = time.time() if now is None else float(now)
|
|
287
|
+
env_mode = _normalize_update_mode(values.get(ENV_MODE))
|
|
288
|
+
mode = env_mode or _normalize_update_mode(result.get("update_mode")) or get_update_mode(env=values)
|
|
289
|
+
if not update_check_enabled(values):
|
|
290
|
+
mode = "never"
|
|
291
|
+
latest_version = str(result.get("latest_version") or "").strip()
|
|
292
|
+
latest_tag = str(result.get("latest_tag") or "").strip()
|
|
293
|
+
|
|
294
|
+
if result.get("status") != "update_available":
|
|
295
|
+
return {"action": "none", "reason": "no_update", "update_mode": mode}
|
|
296
|
+
if mode == "never":
|
|
297
|
+
return {"action": "none", "reason": "never", "update_mode": mode}
|
|
298
|
+
if mode == "notify":
|
|
299
|
+
return {"action": "notify", "reason": "notify_only", "update_mode": mode}
|
|
300
|
+
if _matches_ignored_version(result):
|
|
301
|
+
return {"action": "none", "reason": "ignored", "update_mode": mode}
|
|
302
|
+
|
|
303
|
+
snooze_until = _float_or_none(result.get("snooze_until"))
|
|
304
|
+
if snooze_until and snooze_until > timestamp:
|
|
305
|
+
return {
|
|
306
|
+
"action": "none",
|
|
307
|
+
"reason": "snoozed",
|
|
308
|
+
"update_mode": mode,
|
|
309
|
+
"snooze_until": snooze_until,
|
|
310
|
+
"snooze_until_iso": _format_timestamp(snooze_until),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if mode == "auto":
|
|
314
|
+
return {"action": "auto", "reason": "auto", "update_mode": mode}
|
|
315
|
+
return {
|
|
316
|
+
"action": "prompt",
|
|
317
|
+
"reason": "update_available",
|
|
318
|
+
"update_mode": mode,
|
|
319
|
+
"latest_version": latest_version,
|
|
320
|
+
"latest_tag": latest_tag,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def set_update_mode(
|
|
325
|
+
project_dir: os.PathLike[str] | str,
|
|
326
|
+
mode: str,
|
|
327
|
+
*,
|
|
328
|
+
env: Optional[Mapping[str, str]] = None,
|
|
329
|
+
now: Optional[float] = None,
|
|
330
|
+
) -> Dict[str, Any]:
|
|
331
|
+
"""Persist the local update mode in the update-check state file."""
|
|
332
|
+
normalized = _normalize_update_mode(mode)
|
|
333
|
+
if not normalized:
|
|
334
|
+
raise ValueError(f"Unsupported update mode: {mode!r}")
|
|
335
|
+
state_path = update_state_path(project_dir, env)
|
|
336
|
+
state = _read_state(state_path)
|
|
337
|
+
state["update_mode"] = normalized
|
|
338
|
+
state["updated_at"] = time.time() if now is None else float(now)
|
|
339
|
+
state["updated_at_iso"] = _format_timestamp(state["updated_at"])
|
|
340
|
+
_write_state(state_path, state)
|
|
341
|
+
_set_cached_status(state or {"status": "unknown", "update_mode": normalized})
|
|
342
|
+
return state
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def ignore_update_version(
|
|
346
|
+
project_dir: os.PathLike[str] | str,
|
|
347
|
+
result: Mapping[str, Any],
|
|
348
|
+
*,
|
|
349
|
+
env: Optional[Mapping[str, str]] = None,
|
|
350
|
+
now: Optional[float] = None,
|
|
351
|
+
) -> Dict[str, Any]:
|
|
352
|
+
"""Persist an ignored release version/tag for future prompts."""
|
|
353
|
+
timestamp = time.time() if now is None else float(now)
|
|
354
|
+
state_path = update_state_path(project_dir, env)
|
|
355
|
+
state = _read_state(state_path)
|
|
356
|
+
if result.get("latest_version"):
|
|
357
|
+
state["ignored_version"] = result.get("latest_version")
|
|
358
|
+
if result.get("latest_tag"):
|
|
359
|
+
state["ignored_tag"] = result.get("latest_tag")
|
|
360
|
+
state["ignored_at"] = timestamp
|
|
361
|
+
state["ignored_at_iso"] = _format_timestamp(timestamp)
|
|
362
|
+
state.pop("snooze_until", None)
|
|
363
|
+
state.pop("snooze_until_iso", None)
|
|
364
|
+
_write_state(state_path, state)
|
|
365
|
+
_set_cached_status(state or {"status": "unknown"})
|
|
366
|
+
return state
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def snooze_update_prompt(
|
|
370
|
+
project_dir: os.PathLike[str] | str,
|
|
371
|
+
*,
|
|
372
|
+
hours: Optional[float] = None,
|
|
373
|
+
env: Optional[Mapping[str, str]] = None,
|
|
374
|
+
now: Optional[float] = None,
|
|
375
|
+
) -> Dict[str, Any]:
|
|
376
|
+
"""Persist a temporary update-prompt snooze."""
|
|
377
|
+
values = os.environ if env is None else env
|
|
378
|
+
timestamp = time.time() if now is None else float(now)
|
|
379
|
+
snooze_hours = _snooze_hours(values, hours)
|
|
380
|
+
snooze_until = timestamp + snooze_hours * 60 * 60
|
|
381
|
+
state_path = update_state_path(project_dir, values)
|
|
382
|
+
state = _read_state(state_path)
|
|
383
|
+
state["snooze_until"] = snooze_until
|
|
384
|
+
state["snooze_until_iso"] = _format_timestamp(snooze_until)
|
|
385
|
+
_write_state(state_path, state)
|
|
386
|
+
_set_cached_status(state or {"status": "unknown"})
|
|
387
|
+
return state
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def clear_update_prompt_preferences(
|
|
391
|
+
project_dir: os.PathLike[str] | str,
|
|
392
|
+
*,
|
|
393
|
+
env: Optional[Mapping[str, str]] = None,
|
|
394
|
+
) -> Dict[str, Any]:
|
|
395
|
+
"""Clear ignored-version and snooze state while preserving check results."""
|
|
396
|
+
state_path = update_state_path(project_dir, env)
|
|
397
|
+
state = _read_state(state_path)
|
|
398
|
+
for key in (
|
|
399
|
+
"ignored_version",
|
|
400
|
+
"ignored_tag",
|
|
401
|
+
"ignored_at",
|
|
402
|
+
"ignored_at_iso",
|
|
403
|
+
"snooze_until",
|
|
404
|
+
"snooze_until_iso",
|
|
405
|
+
):
|
|
406
|
+
state.pop(key, None)
|
|
407
|
+
_write_state(state_path, state)
|
|
408
|
+
_set_cached_status(state or {"status": "unknown"})
|
|
409
|
+
return state
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _normalize_update_mode(value: Any) -> Optional[str]:
|
|
413
|
+
text = str(value or "").strip().lower().replace("_", "-")
|
|
414
|
+
aliases = {
|
|
415
|
+
"": "",
|
|
416
|
+
"ask": "prompt",
|
|
417
|
+
"manual": "prompt",
|
|
418
|
+
"prompt": "prompt",
|
|
419
|
+
"auto": "auto",
|
|
420
|
+
"automatic": "auto",
|
|
421
|
+
"autoupdate": "auto",
|
|
422
|
+
"auto-update": "auto",
|
|
423
|
+
"check": "notify",
|
|
424
|
+
"check-only": "notify",
|
|
425
|
+
"inform": "notify",
|
|
426
|
+
"informational": "notify",
|
|
427
|
+
"notify": "notify",
|
|
428
|
+
"off": "never",
|
|
429
|
+
"disable": "never",
|
|
430
|
+
"disabled": "never",
|
|
431
|
+
"never": "never",
|
|
432
|
+
"none": "never",
|
|
433
|
+
}
|
|
434
|
+
normalized = aliases.get(text, text)
|
|
435
|
+
return normalized if normalized in _UPDATE_MODES else None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _merge_persistent_state(
|
|
439
|
+
previous: Mapping[str, Any],
|
|
440
|
+
result: Mapping[str, Any],
|
|
441
|
+
) -> Dict[str, Any]:
|
|
442
|
+
merged = dict(result)
|
|
443
|
+
for key in _PERSISTENT_STATE_KEYS:
|
|
444
|
+
if key in previous and key not in merged:
|
|
445
|
+
merged[key] = previous[key]
|
|
446
|
+
return merged
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _matches_ignored_version(result: Mapping[str, Any]) -> bool:
|
|
450
|
+
ignored_version = str(result.get("ignored_version") or "").strip()
|
|
451
|
+
ignored_tag = str(result.get("ignored_tag") or "").strip()
|
|
452
|
+
latest_version = str(result.get("latest_version") or "").strip()
|
|
453
|
+
latest_tag = str(result.get("latest_tag") or "").strip()
|
|
454
|
+
return bool(
|
|
455
|
+
(ignored_version and ignored_version == latest_version)
|
|
456
|
+
or (ignored_tag and ignored_tag == latest_tag)
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _float_or_none(value: Any) -> Optional[float]:
|
|
461
|
+
try:
|
|
462
|
+
return float(value)
|
|
463
|
+
except (TypeError, ValueError):
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _snooze_hours(env: Mapping[str, str], override: Optional[float]) -> float:
|
|
468
|
+
if override is not None:
|
|
469
|
+
try:
|
|
470
|
+
return max(float(override), 0.1)
|
|
471
|
+
except (TypeError, ValueError):
|
|
472
|
+
return DEFAULT_SNOOZE_HOURS
|
|
473
|
+
raw = env.get(ENV_SNOOZE_HOURS)
|
|
474
|
+
if raw is None:
|
|
475
|
+
return DEFAULT_SNOOZE_HOURS
|
|
476
|
+
try:
|
|
477
|
+
return max(float(raw), 0.1)
|
|
478
|
+
except (TypeError, ValueError):
|
|
479
|
+
return DEFAULT_SNOOZE_HOURS
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _interval_seconds(env: Mapping[str, str]) -> float:
|
|
483
|
+
raw = env.get(ENV_INTERVAL_HOURS)
|
|
484
|
+
if raw is None:
|
|
485
|
+
return DEFAULT_INTERVAL_HOURS * 60 * 60
|
|
486
|
+
try:
|
|
487
|
+
hours = float(raw)
|
|
488
|
+
except (TypeError, ValueError):
|
|
489
|
+
return DEFAULT_INTERVAL_HOURS * 60 * 60
|
|
490
|
+
return max(hours, 0.1) * 60 * 60
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _release_api_url(env: Mapping[str, str]) -> str:
|
|
494
|
+
if env.get(ENV_URL):
|
|
495
|
+
return str(env[ENV_URL])
|
|
496
|
+
repo = str(env.get(ENV_REPO) or DEFAULT_REPO).strip().strip("/")
|
|
497
|
+
return f"https://api.github.com/repos/{repo}/releases/latest"
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _fetch_latest_release(env: Mapping[str, str], timeout: float) -> Dict[str, Any]:
|
|
501
|
+
url = _release_api_url(env)
|
|
502
|
+
request = urllib.request.Request(
|
|
503
|
+
url,
|
|
504
|
+
headers={
|
|
505
|
+
"Accept": "application/vnd.github+json",
|
|
506
|
+
"User-Agent": "davinci-resolve-mcp-update-check",
|
|
507
|
+
},
|
|
508
|
+
)
|
|
509
|
+
try:
|
|
510
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
511
|
+
data = response.read().decode("utf-8")
|
|
512
|
+
except urllib.error.HTTPError as exc:
|
|
513
|
+
raise RuntimeError(f"GitHub update check failed with HTTP {exc.code}") from exc
|
|
514
|
+
except urllib.error.URLError as exc:
|
|
515
|
+
raise RuntimeError(f"GitHub update check failed: {exc.reason}") from exc
|
|
516
|
+
payload = json.loads(data)
|
|
517
|
+
if not isinstance(payload, dict):
|
|
518
|
+
raise RuntimeError("GitHub update check returned an unexpected response")
|
|
519
|
+
return payload
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _result_from_release(
|
|
523
|
+
current_version: str,
|
|
524
|
+
release: Mapping[str, Any],
|
|
525
|
+
checked_at: float,
|
|
526
|
+
env: Mapping[str, str],
|
|
527
|
+
) -> Dict[str, Any]:
|
|
528
|
+
latest_tag = str(release.get("tag_name") or release.get("name") or "").strip()
|
|
529
|
+
latest_version = _version_text(latest_tag)
|
|
530
|
+
comparison = compare_versions(current_version, latest_version)
|
|
531
|
+
if comparison is None:
|
|
532
|
+
status = "unknown"
|
|
533
|
+
elif comparison < 0:
|
|
534
|
+
status = "update_available"
|
|
535
|
+
elif comparison > 0:
|
|
536
|
+
status = "current_ahead"
|
|
537
|
+
else:
|
|
538
|
+
status = "up_to_date"
|
|
539
|
+
|
|
540
|
+
repo = str(env.get(ENV_REPO) or DEFAULT_REPO).strip().strip("/")
|
|
541
|
+
return {
|
|
542
|
+
"status": status,
|
|
543
|
+
"current_version": current_version,
|
|
544
|
+
"latest_version": latest_version,
|
|
545
|
+
"latest_tag": latest_tag,
|
|
546
|
+
"release_url": release.get("html_url")
|
|
547
|
+
or f"https://github.com/{repo}/releases/latest",
|
|
548
|
+
"checked_at": checked_at,
|
|
549
|
+
"checked_at_iso": _format_timestamp(checked_at),
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _version_text(value: Any) -> str:
|
|
554
|
+
match = re.search(r"\d+(?:\.\d+)*", str(value or ""))
|
|
555
|
+
return match.group(0) if match else str(value or "").strip()
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _read_state(path: Path) -> Dict[str, Any]:
|
|
559
|
+
try:
|
|
560
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
561
|
+
data = json.load(handle)
|
|
562
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
563
|
+
return {}
|
|
564
|
+
return data if isinstance(data, dict) else {}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _write_state(path: Path, result: Mapping[str, Any]) -> None:
|
|
568
|
+
try:
|
|
569
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
570
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
571
|
+
json.dump(result, handle, indent=2, sort_keys=True)
|
|
572
|
+
handle.write("\n")
|
|
573
|
+
except OSError:
|
|
574
|
+
return
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _set_cached_status(result: Mapping[str, Any]) -> None:
|
|
578
|
+
with _cached_lock:
|
|
579
|
+
_cached_status.clear()
|
|
580
|
+
_cached_status.update(dict(result))
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _format_timestamp(timestamp: float) -> str:
|
|
584
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat().replace(
|
|
585
|
+
"+00:00", "Z"
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _log_result(logger: Any, result: Mapping[str, Any]) -> None:
|
|
590
|
+
status = result.get("status")
|
|
591
|
+
if status == "update_available":
|
|
592
|
+
logger.warning(
|
|
593
|
+
"DaVinci Resolve MCP update available: current v%s, latest v%s. "
|
|
594
|
+
"Update from %s",
|
|
595
|
+
result.get("current_version"),
|
|
596
|
+
result.get("latest_version"),
|
|
597
|
+
result.get("release_url"),
|
|
598
|
+
)
|
|
599
|
+
elif status == "up_to_date":
|
|
600
|
+
logger.info("DaVinci Resolve MCP is up to date at v%s", result.get("current_version"))
|
|
601
|
+
elif status == "current_ahead":
|
|
602
|
+
logger.info(
|
|
603
|
+
"DaVinci Resolve MCP local version v%s is newer than latest release v%s",
|
|
604
|
+
result.get("current_version"),
|
|
605
|
+
result.get("latest_version"),
|
|
606
|
+
)
|
|
607
|
+
elif status == "disabled":
|
|
608
|
+
logger.info("DaVinci Resolve MCP update checks are disabled")
|
|
609
|
+
elif status == "error":
|
|
610
|
+
logger.warning("DaVinci Resolve MCP update check failed: %s", result.get("error"))
|