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.
Files changed (92) hide show
  1. package/AGENTS.md +85 -0
  2. package/CHANGELOG.md +802 -0
  3. package/CLAUDE.md +15 -0
  4. package/LICENSE +21 -0
  5. package/README.md +159 -0
  6. package/SECURITY.md +53 -0
  7. package/bin/davinci-resolve-mcp.mjs +376 -0
  8. package/docs/README.md +56 -0
  9. package/docs/SKILL.md +1145 -0
  10. package/docs/authoring/fuse-dctl-authoring.md +242 -0
  11. package/docs/authoring/script-plugin-authoring.md +195 -0
  12. package/docs/contributing.md +82 -0
  13. package/docs/guides/color-decision-guide.md +387 -0
  14. package/docs/guides/editorial-decision-guide.md +136 -0
  15. package/docs/guides/media-analysis-guide.md +615 -0
  16. package/docs/guides/multicam-setup-guide.md +138 -0
  17. package/docs/install.md +198 -0
  18. package/docs/integrations/workflow-integrations.md +120 -0
  19. package/docs/kernels/README.md +28 -0
  20. package/docs/kernels/audio-fairlight-kernel.md +86 -0
  21. package/docs/kernels/color-grade-kernel.md +103 -0
  22. package/docs/kernels/extension-authoring-kernel.md +101 -0
  23. package/docs/kernels/fusion-composition-kernel.md +91 -0
  24. package/docs/kernels/media-pool-ingest-kernel.md +147 -0
  25. package/docs/kernels/project-lifecycle-kernel.md +120 -0
  26. package/docs/kernels/render-deliver-kernel.md +92 -0
  27. package/docs/kernels/review-annotation-kernel.md +110 -0
  28. package/docs/kernels/timeline-conform-interchange-kernel.md +99 -0
  29. package/docs/kernels/timeline-edit-kernel.md +189 -0
  30. package/docs/notes/codec-plugin-notes.md +136 -0
  31. package/docs/notes/dctl-notes.md +234 -0
  32. package/docs/notes/fusion-template-notes.md +136 -0
  33. package/docs/notes/lut-notes.md +136 -0
  34. package/docs/notes/openfx-notes.md +120 -0
  35. package/docs/process/release-process.md +152 -0
  36. package/docs/reference/api-coverage.md +488 -0
  37. package/docs/reference/resolve_scripting_api.txt +1012 -0
  38. package/examples/README.md +53 -0
  39. package/examples/markers/README.md +81 -0
  40. package/examples/media/README.md +94 -0
  41. package/examples/timeline/README.md +98 -0
  42. package/install.py +1196 -0
  43. package/package.json +52 -0
  44. package/scripts/audit_api_parity.py +275 -0
  45. package/scripts/live_media_analysis_polish_probe.py +65 -0
  46. package/src/__init__.py +3 -0
  47. package/src/analysis_dashboard.py +4936 -0
  48. package/src/control_panel.py +13 -0
  49. package/src/granular/__init__.py +17 -0
  50. package/src/granular/common.py +727 -0
  51. package/src/granular/folder.py +287 -0
  52. package/src/granular/gallery.py +306 -0
  53. package/src/granular/graph.py +309 -0
  54. package/src/granular/media_pool.py +679 -0
  55. package/src/granular/media_pool_item.py +852 -0
  56. package/src/granular/media_storage.py +179 -0
  57. package/src/granular/project.py +1594 -0
  58. package/src/granular/resolve_control.py +521 -0
  59. package/src/granular/timeline.py +1074 -0
  60. package/src/granular/timeline_item.py +2251 -0
  61. package/src/resolve_mcp_server.py +43 -0
  62. package/src/server.py +15691 -0
  63. package/src/utils/__init__.py +3 -0
  64. package/src/utils/app_control.py +319 -0
  65. package/src/utils/audio_fairlight_live_probe.py +263 -0
  66. package/src/utils/cdl.py +20 -0
  67. package/src/utils/cloud_operations.py +192 -0
  68. package/src/utils/color_grade_live_probe.py +444 -0
  69. package/src/utils/dctl_templates.py +368 -0
  70. package/src/utils/extension_authoring_live_probe.py +292 -0
  71. package/src/utils/fuse_templates.py +1968 -0
  72. package/src/utils/fusion_composition_live_probe.py +284 -0
  73. package/src/utils/layout_presets.py +333 -0
  74. package/src/utils/mcp_stdio.py +32 -0
  75. package/src/utils/media_analysis.py +3618 -0
  76. package/src/utils/media_analysis_jobs.py +796 -0
  77. package/src/utils/media_pool_ingest_live_probe.py +592 -0
  78. package/src/utils/multicam.py +393 -0
  79. package/src/utils/object_inspection.py +287 -0
  80. package/src/utils/platform.py +157 -0
  81. package/src/utils/project_lifecycle_live_probe.py +376 -0
  82. package/src/utils/project_properties.py +601 -0
  83. package/src/utils/render_deliver_live_probe.py +384 -0
  84. package/src/utils/resolve_connection.py +77 -0
  85. package/src/utils/review_annotation_live_probe.py +352 -0
  86. package/src/utils/script_templates.py +1193 -0
  87. package/src/utils/sync_detection.py +887 -0
  88. package/src/utils/timeline_conform_live_probe.py +280 -0
  89. package/src/utils/timeline_kernel_live_probe.py +1091 -0
  90. package/src/utils/timeline_kernel_probe.py +185 -0
  91. package/src/utils/timeline_title_text.py +87 -0
  92. 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"))