davinci-resolve-mcp 2.27.0 → 2.27.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.27.1
6
+
7
+ **Faster control-panel startup with network source media (issue #50)** — on
8
+ first open the control panel could sit on "connection pending" for a long time
9
+ when Media Pool clips lived on mounted network storage, because the UI only
10
+ treated the connection as live once the full media inventory finished loading,
11
+ and that inventory probed every clip's file path on disk.
12
+
13
+ Fixes and performance work:
14
+
15
+ - **Connection state is decoupled from the media inventory.** The overview and
16
+ diagnostics panels now derive "connected" from the `/api/boot` handshake (which
17
+ returns as soon as the Resolve bridge is reachable) and show inventory loading
18
+ separately, so Resolve reads as live immediately while clips stream in.
19
+ - **Parallel, cached file-existence probing.** `os.path.exists` for every clip
20
+ now runs in a thread pool and is memoized for a short TTL, instead of two serial
21
+ `stat()` calls per clip — the dominant cost on network storage.
22
+ - **Background polls reuse the cached Media Pool walk.** The recurring poll no
23
+ longer re-runs the ~N serial `GetClipProperty` calls; it reuses the last walk
24
+ and re-applies only the local, disk-backed analysis-status overlay. A cheap
25
+ project-id check still catches a project switched directly in Resolve, and a
26
+ manual refresh always does a full walk.
27
+ - **Resolve scripting API access is serialized.** A re-entrant lock guards every
28
+ scripting-API entry point, since the dashboard's threaded HTTP server could
29
+ previously fire concurrent (thread-unsafe) Resolve calls at startup.
30
+ - **ETag/304 on the inventory endpoint** skips transfer and table re-render when
31
+ nothing changed; the last good inventory is cached client-side and painted
32
+ instantly on reload; and the first inventory build is warmed in a background
33
+ thread at server start.
34
+
35
+ No public MCP tool surface changed. Adds regression tests in
36
+ `tests/test_media_analysis.py` (path-existence probing, inventory cache reuse,
37
+ project-switch detection, lock reentrancy).
38
+
5
39
  ## What's New in v2.27.0
6
40
 
7
41
  **Frame-sampling modes (issue #46)** — how many frames a clip gets for visual
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.27.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.27.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-32%20(329%20full)-blue.svg)](#server-modes)
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.27.0"
38
+ VERSION = "2.27.1"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.27.0",
3
+ "version": "2.27.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -4,13 +4,17 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import functools
8
+ import hashlib
7
9
  import json
8
10
  import os
9
11
  import re
10
12
  import sqlite3
11
13
  import sys
12
14
  import threading
15
+ import time
13
16
  import webbrowser
17
+ from concurrent.futures import ThreadPoolExecutor
14
18
  from http import HTTPStatus
15
19
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
20
  from typing import Any, Dict, List, Optional, Tuple
@@ -4636,6 +4640,8 @@ HTML = r"""<!doctype html>
4636
4640
  allProjects: null,
4637
4641
  activeContext: null,
4638
4642
  resolveMedia: null,
4643
+ resolveMediaStale: false,
4644
+ mediaETag: null,
4639
4645
  mediaPollTimer: null,
4640
4646
  mediaRefreshing: false,
4641
4647
  mediaLastRefresh: null,
@@ -5032,18 +5038,34 @@ HTML = r"""<!doctype html>
5032
5038
  const clips = sourceClips();
5033
5039
  const hiddenRecords = Math.max(0, Number(counts.total || 0) - Number(counts.source_clips || clips.length));
5034
5040
  const sequences = sequenceCount(media);
5041
+ // Connection state comes from the /api/boot handshake, which returns as soon
5042
+ // as the Resolve bridge is reachable — independent of the media inventory,
5043
+ // which can take a long time to probe network source media. inventoryPending
5044
+ // means Resolve is live but /api/resolve/media hasn't returned yet.
5045
+ const bootResolve = state.boot?.resolve || {};
5046
+ const resolveConnected = bootResolve.available === true || !!media?.resolve_available;
5047
+ const inventoryPending = resolveConnected && !media;
5035
5048
  const projectName = state.activeContext?.project_name || media?.project?.name || state.boot?.project_name || 'Resolve project';
5036
- const resolveProject = media?.project?.name || 'No Resolve project';
5037
- const resolveStatus = media?.resolve_available ? `Resolve: ${resolveProject} · read-only` : (media?.status || 'Connection pending');
5049
+ const resolveProject = media?.project?.name || (resolveConnected ? 'Loading project…' : 'No Resolve project');
5050
+ let resolveStatus;
5051
+ if (media?.resolve_available) {
5052
+ resolveStatus = `Resolve: ${resolveProject} · read-only`;
5053
+ } else if (inventoryPending) {
5054
+ resolveStatus = 'Resolve connected · loading inventory…';
5055
+ } else if (resolveConnected) {
5056
+ resolveStatus = media?.status || media?.error || 'Resolve connected';
5057
+ } else {
5058
+ resolveStatus = media?.status || bootResolve.error || 'Connection pending';
5059
+ }
5038
5060
  const index = indexSummary();
5039
5061
  const readyClips = clips.filter(clip => clip.analyzable).length;
5040
5062
  const analyzedClips = clips.filter(clip => ['analyzed', 'succeeded', 'skipped'].includes(String(clip.analysis_status || ''))).length;
5041
5063
  const onlineClips = clips.filter(clip => String(clip.status || '') === 'online').length;
5042
5064
  const missingClips = clips.filter(clip => ['missing_file', 'offline'].includes(String(clip.status || ''))).length;
5043
- const mediaStatusLabel = media?.resolve_available ? `${onlineClips} online` : 'Unavailable';
5065
+ const mediaStatusLabel = media?.resolve_available ? `${onlineClips} online` : (inventoryPending ? 'Loading…' : 'Unavailable');
5044
5066
  const mediaStatusDetail = media?.resolve_available
5045
5067
  ? `${missingClips} missing/offline · ${hiddenRecords} non-source records`
5046
- : (media?.error || media?.status || 'Resolve inventory pending');
5068
+ : (inventoryPending ? 'Reading Media Pool inventory…' : (media?.error || media?.status || 'Resolve inventory pending'));
5047
5069
 
5048
5070
  setText('overviewUpdated', `Updated ${new Date().toLocaleTimeString()}`);
5049
5071
  setText('overviewProject', projectName);
@@ -5056,7 +5078,10 @@ HTML = r"""<!doctype html>
5056
5078
  setText('overviewMediaStatusDetail', mediaStatusDetail);
5057
5079
 
5058
5080
  if (!media?.resolve_available) {
5059
- setHtml('overviewStatusList', `<div class="empty">${escapeHtml(media?.error || 'Open Resolve with a project loaded to inspect clips.')}</div>`);
5081
+ const emptyMsg = inventoryPending
5082
+ ? 'Resolve connected — loading Media Pool inventory…'
5083
+ : (media?.error || 'Open Resolve with a project loaded to inspect clips.');
5084
+ setHtml('overviewStatusList', `<div class="empty">${escapeHtml(emptyMsg)}</div>`);
5060
5085
  } else {
5061
5086
  const ICONS = {
5062
5087
  project: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h5l2 3h11v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
@@ -5109,9 +5134,13 @@ HTML = r"""<!doctype html>
5109
5134
  const index = indexSummary();
5110
5135
  const resolveInfo = state.boot?.resolve || {};
5111
5136
  const warnings = media?.warnings || [];
5112
- const resolveOnline = !!media?.resolve_available;
5113
- const resolveTone = resolveOnline ? 'pill-ok' : (media ? 'pill-err' : 'pill-mute');
5114
- const resolveLabel = resolveOnline ? 'Connected' : (media ? 'Offline' : 'Pending');
5137
+ // The boot handshake establishes the connection; media inventory loads after.
5138
+ const handshake = resolveInfo.available === true;
5139
+ const resolveOnline = !!media?.resolve_available || handshake;
5140
+ const inventoryPending = handshake && !media;
5141
+ const offline = (media || resolveInfo.error) && !resolveOnline;
5142
+ const resolveTone = resolveOnline ? 'pill-ok' : (offline ? 'pill-err' : 'pill-mute');
5143
+ const resolveLabel = resolveOnline ? 'Connected' : (offline ? 'Offline' : 'Pending');
5115
5144
 
5116
5145
  const connectionCard = `
5117
5146
  <div class="diag-card">
@@ -5123,7 +5152,7 @@ HTML = r"""<!doctype html>
5123
5152
  ${diagRow('Product', resolveInfo.product || (resolveOnline ? 'DaVinci Resolve' : 'Unavailable'), { muted: !resolveOnline })}
5124
5153
  ${diagRow('Version', resolveInfo.version_string || '—', { muted: !resolveInfo.version_string })}
5125
5154
  ${diagRow('Active page', resolveInfo.page || '—', { muted: !resolveInfo.page })}
5126
- ${diagRow('Status', resolveOnline ? 'Read-only API live' : (media?.error || media?.status || 'Waiting for handshake'))}
5155
+ ${diagRow('Status', inventoryPending ? 'Read-only API live · loading inventory…' : (resolveOnline ? 'Read-only API live' : (media?.error || resolveInfo.error || media?.status || 'Waiting for handshake')))}
5127
5156
  </div>
5128
5157
  </div>`;
5129
5158
 
@@ -5926,6 +5955,14 @@ HTML = r"""<!doctype html>
5926
5955
  syncPreferencesPanel();
5927
5956
  applyPreferencesToControls(prefs);
5928
5957
  renderVersionBadge();
5958
+ // Paint the previous inventory immediately (stale) so a slow first
5959
+ // /api/resolve/media — common with network source media — doesn't leave the
5960
+ // panel blank. The live fetch below replaces it and clears the stale flag.
5961
+ const cachedInventory = loadInventorySnapshot();
5962
+ if (cachedInventory) {
5963
+ state.resolveMediaStale = true;
5964
+ renderResolveMedia(cachedInventory);
5965
+ }
5929
5966
  await refreshProjectContexts();
5930
5967
  renderControlPanels();
5931
5968
  await refreshIndex();
@@ -6057,15 +6094,56 @@ HTML = r"""<!doctype html>
6057
6094
  state.mediaRefreshing = true;
6058
6095
  updateMediaPollStatus('refreshing');
6059
6096
  try {
6060
- const payload = await api('/api/resolve/media?limit=500');
6097
+ // Background polls reuse the cached Resolve walk and skip the network FS
6098
+ // probe (the server re-applies only the local analysis overlay); manual /
6099
+ // first loads do a full Media Pool walk with a fresh probe. The ETag lets
6100
+ // an unchanged poll short-circuit to 304 and skip the table re-render.
6101
+ const query = options.silent ? '&probe=0&reuse=1' : '';
6102
+ const headers = {};
6103
+ if (state.mediaETag) headers['If-None-Match'] = state.mediaETag;
6104
+ const res = await fetch(`/api/resolve/media?limit=500${query}`, { headers, cache: 'no-store' });
6061
6105
  state.mediaLastRefresh = new Date();
6106
+ state.resolveMediaStale = false;
6107
+ if (res.status === 304) return;
6108
+ const payload = await res.json();
6109
+ if (!res.ok || payload.success === false) {
6110
+ throw new Error(payload.error || res.statusText);
6111
+ }
6112
+ state.mediaETag = res.headers.get('ETag') || state.mediaETag;
6062
6113
  renderResolveMedia(payload);
6114
+ saveInventorySnapshot(payload);
6063
6115
  } finally {
6064
6116
  state.mediaRefreshing = false;
6065
6117
  updateMediaPollStatus();
6066
6118
  }
6067
6119
  }
6068
6120
 
6121
+ // Persist the last good inventory so reopening the dashboard paints the
6122
+ // previous snapshot instantly (with a "refreshing" hint) instead of sitting
6123
+ // on "connection pending" until the first fetch returns.
6124
+ function inventorySnapshotKey() {
6125
+ return 'resolveMcpInventory:' + (state.activeContext?.project_root || state.boot?.project_root || state.boot?.project_name || 'default');
6126
+ }
6127
+ function saveInventorySnapshot(payload) {
6128
+ if (!payload?.resolve_available) return;
6129
+ try {
6130
+ localStorage.setItem(inventorySnapshotKey(), JSON.stringify({ saved_at: Date.now(), payload }));
6131
+ } catch (error) {
6132
+ // Quota or serialization failure is non-fatal — the snapshot is a nicety.
6133
+ console.warn('Could not cache inventory snapshot', error);
6134
+ }
6135
+ }
6136
+ function loadInventorySnapshot() {
6137
+ try {
6138
+ const raw = localStorage.getItem(inventorySnapshotKey());
6139
+ if (!raw) return null;
6140
+ const parsed = JSON.parse(raw);
6141
+ return parsed?.payload?.resolve_available ? parsed.payload : null;
6142
+ } catch (error) {
6143
+ return null;
6144
+ }
6145
+ }
6146
+
6069
6147
  function promptCandidateClips() {
6070
6148
  return filteredResolveClips(state.resolveMedia?.clips || [])
6071
6149
  .filter(clip => clip.source_clip && clip.analyzable && clip.clip_id);
@@ -6550,7 +6628,8 @@ HTML = r"""<!doctype html>
6550
6628
  const poll = enabled ? `polling every ${Math.round(interval / 1000)}s` : 'polling off';
6551
6629
  const visible = state.resolveMedia?.clips ? clipLabel(filteredResolveClips(state.resolveMedia.clips).length) : 'no media snapshot';
6552
6630
  const prefix = state.mediaRefreshing ? 'refreshing' : poll;
6553
- el.textContent = `${prefix} · last ${last} · ${visible}${extra ? ` · ${extra}` : ''}`;
6631
+ const stale = state.resolveMediaStale ? 'cached · ' : '';
6632
+ el.textContent = `${stale}${prefix} · last ${last} · ${visible}${extra ? ` · ${extra}` : ''}`;
6554
6633
  }
6555
6634
 
6556
6635
  function rerenderResolveMedia() {
@@ -10504,27 +10583,74 @@ def _safe_id(obj: Any) -> Optional[str]:
10504
10583
  return str(value) if value else None
10505
10584
 
10506
10585
 
10586
+ # ── Resolve scripting API serialization ─────────────────────────────────────
10587
+ # The dashboard runs on a ThreadingHTTPServer, so /api/boot, /api/projects and
10588
+ # /api/resolve/media can land on separate threads concurrently (especially at
10589
+ # startup). DaVinci's scripting API is not thread-safe, so every entry point that
10590
+ # talks to it acquires this re-entrant lock for the full duration of its calls.
10591
+ _RESOLVE_API_LOCK = threading.RLock()
10592
+ _RESOLVE_ENV_READY = False
10593
+
10594
+
10595
+ def _serialize_resolve(func):
10596
+ """Decorator: hold the Resolve API lock for the whole call."""
10597
+ @functools.wraps(func)
10598
+ def wrapper(*args, **kwargs):
10599
+ with _RESOLVE_API_LOCK:
10600
+ return func(*args, **kwargs)
10601
+ return wrapper
10602
+
10603
+
10507
10604
  def _connect_resolve_read_only() -> Tuple[Any, Optional[str]]:
10508
- try:
10509
- setup_environment()
10510
- paths = os.environ.get("PYTHONPATH", "")
10511
- modules_path = os.environ.get("RESOLVE_SCRIPT_API")
10512
- if modules_path:
10513
- candidate = os.path.join(modules_path, "Modules")
10514
- if candidate not in sys.path:
10515
- sys.path.append(candidate)
10516
- import DaVinciResolveScript as dvr_script # type: ignore
10517
- except Exception as exc:
10518
- return None, f"Resolve scripting API unavailable: {exc}"
10519
- try:
10520
- resolve = dvr_script.scriptapp("Resolve")
10521
- except Exception as exc:
10522
- return None, f"Resolve connection failed: {exc}"
10523
- if resolve is None:
10524
- return None, "DaVinci Resolve is not connected. Open Resolve Studio with a project loaded."
10525
- return resolve, None
10605
+ global _RESOLVE_ENV_READY
10606
+ with _RESOLVE_API_LOCK:
10607
+ # Environment + sys.path setup is pure overhead and never goes stale, so
10608
+ # run it once per process rather than on every connection.
10609
+ if not _RESOLVE_ENV_READY:
10610
+ try:
10611
+ setup_environment()
10612
+ modules_path = os.environ.get("RESOLVE_SCRIPT_API")
10613
+ if modules_path:
10614
+ candidate = os.path.join(modules_path, "Modules")
10615
+ if candidate not in sys.path:
10616
+ sys.path.append(candidate)
10617
+ _RESOLVE_ENV_READY = True
10618
+ except Exception as exc:
10619
+ return None, f"Resolve scripting API unavailable: {exc}"
10620
+ try:
10621
+ import DaVinciResolveScript as dvr_script # type: ignore
10622
+ except Exception as exc:
10623
+ return None, f"Resolve scripting API unavailable: {exc}"
10624
+ try:
10625
+ resolve = dvr_script.scriptapp("Resolve")
10626
+ except Exception as exc:
10627
+ return None, f"Resolve connection failed: {exc}"
10628
+ if resolve is None:
10629
+ return None, "DaVinci Resolve is not connected. Open Resolve Studio with a project loaded."
10630
+ return resolve, None
10631
+
10632
+
10633
+ @_serialize_resolve
10634
+ def _current_resolve_project_id() -> Tuple[Optional[str], Optional[str]]:
10635
+ """(project_id, error) for the currently-open Resolve project.
10636
+
10637
+ A handful of cheap API calls — used by the media-poll reuse path to detect
10638
+ when the user has switched projects in Resolve since the inventory was cached,
10639
+ without paying for a full Media Pool walk.
10640
+ """
10641
+ resolve, error = _connect_resolve_read_only()
10642
+ if error or resolve is None:
10643
+ return None, error or "Resolve unavailable"
10644
+ pm, pm_error = _safe_call(resolve, "GetProjectManager")
10645
+ if not pm or pm_error:
10646
+ return None, pm_error or "Project manager unavailable"
10647
+ project, _ = _safe_call(pm, "GetCurrentProject")
10648
+ if not project:
10649
+ return None, "No Resolve project open"
10650
+ return _safe_id(project), None
10526
10651
 
10527
10652
 
10653
+ @_serialize_resolve
10528
10654
  def _resolve_identity() -> Dict[str, Any]:
10529
10655
  resolve, error = _connect_resolve_read_only()
10530
10656
  if not resolve:
@@ -10555,7 +10681,64 @@ def _first_prop(props: Dict[str, Any], keys: Tuple[str, ...]) -> Any:
10555
10681
  return None
10556
10682
 
10557
10683
 
10558
- def _media_status(props: Dict[str, Any], file_path: Optional[str]) -> str:
10684
+ # ── File-existence probing ──────────────────────────────────────────────────
10685
+ # stat() calls on mounted network storage dominate inventory time (300+ source
10686
+ # clips on a Z:\ share can take tens of seconds serially). We probe paths in a
10687
+ # thread pool and memoize results for a short TTL so the recurring media poll
10688
+ # does not re-stat unchanged paths every few seconds.
10689
+ _PATH_EXISTS_TTL = 60.0
10690
+ _PATH_PROBE_WORKERS = 16
10691
+ _PATH_EXISTS_CACHE: Dict[str, Tuple[float, bool]] = {}
10692
+ _PATH_EXISTS_LOCK = threading.Lock()
10693
+
10694
+
10695
+ def _cached_path_exists(path: str, now: float, ttl: float) -> Optional[bool]:
10696
+ with _PATH_EXISTS_LOCK:
10697
+ entry = _PATH_EXISTS_CACHE.get(path)
10698
+ if entry is not None and (now - entry[0]) <= ttl:
10699
+ return entry[1]
10700
+ return None
10701
+
10702
+
10703
+ def _store_path_exists(path: str, exists: bool, now: float) -> None:
10704
+ with _PATH_EXISTS_LOCK:
10705
+ _PATH_EXISTS_CACHE[path] = (now, exists)
10706
+
10707
+
10708
+ def _probe_paths_exist(paths: Any, *, probe: bool = True, ttl: float = _PATH_EXISTS_TTL) -> Dict[str, bool]:
10709
+ """Resolve a collection of file paths to existence booleans.
10710
+
10711
+ With ``probe=True`` (first load / manual refresh) any cache entry older than
10712
+ ``ttl`` is re-stat'd, and uncached paths are probed in parallel. With
10713
+ ``probe=False`` (background poll) the filesystem is never touched: cached
10714
+ values are reused at any age and unknown paths fall back to ``True`` —
10715
+ Resolve's own online/offline Status property still flags clips it knows are
10716
+ missing, so we trust it rather than paying for a network round-trip on every
10717
+ poll.
10718
+ """
10719
+ distinct = {str(p) for p in paths if p}
10720
+ result: Dict[str, bool] = {}
10721
+ to_probe: List[str] = []
10722
+ now = time.time()
10723
+ lookup_ttl = ttl if probe else float("inf")
10724
+ for path in distinct:
10725
+ cached = _cached_path_exists(path, now, lookup_ttl)
10726
+ if cached is not None:
10727
+ result[path] = cached
10728
+ elif probe:
10729
+ to_probe.append(path)
10730
+ else:
10731
+ result[path] = True
10732
+ if to_probe:
10733
+ workers = max(1, min(_PATH_PROBE_WORKERS, len(to_probe)))
10734
+ with ThreadPoolExecutor(max_workers=workers) as pool:
10735
+ for path, exists in zip(to_probe, pool.map(os.path.exists, to_probe)):
10736
+ result[path] = bool(exists)
10737
+ _store_path_exists(path, bool(exists), now)
10738
+ return result
10739
+
10740
+
10741
+ def _media_status(props: Dict[str, Any], file_path: Optional[str], *, file_exists: Optional[bool] = None) -> str:
10559
10742
  status_text = str(_first_prop(props, ("Status", "Media Status", "Online Status", "Offline")) or "").strip().lower()
10560
10743
  if not file_path:
10561
10744
  return "no_path"
@@ -10563,7 +10746,11 @@ def _media_status(props: Dict[str, Any], file_path: Optional[str]) -> str:
10563
10746
  return "offline"
10564
10747
  if "missing" in status_text:
10565
10748
  return "missing_file"
10566
- if not os.path.exists(str(file_path)):
10749
+ # Pass `file_exists` in to reuse a single os.path.exists() probe — stat calls on
10750
+ # network source media are slow, so the caller avoids probing the same path twice.
10751
+ if file_exists is None:
10752
+ file_exists = os.path.exists(str(file_path))
10753
+ if not file_exists:
10567
10754
  return "missing_file"
10568
10755
  return "online"
10569
10756
 
@@ -10644,9 +10831,18 @@ def _resolve_clip_record(clip: Any, bin_path: str, selected_ids: set) -> Dict[st
10644
10831
  "resolve_status": _first_prop(props, ("Status", "Media Status", "Online Status", "Offline")),
10645
10832
  "selected": bool(clip_id and clip_id in selected_ids),
10646
10833
  }
10647
- record["file_exists"] = bool(record["file_path"] and os.path.exists(str(record["file_path"])))
10648
- record["status"] = _media_status(props, record["file_path"])
10834
+ # File-existence is resolved in a single parallel batch after every clip's
10835
+ # Resolve properties are gathered (see resolve_media_inventory), so we stash
10836
+ # the props and defer existence-dependent fields to _finalize_clip_record.
10649
10837
  record["clip_key"] = stable_clip_directory(record)
10838
+ record["_props"] = props
10839
+ return record
10840
+
10841
+
10842
+ def _finalize_clip_record(record: Dict[str, Any], file_exists: bool) -> Dict[str, Any]:
10843
+ props = record.pop("_props", {}) or {}
10844
+ record["file_exists"] = file_exists
10845
+ record["status"] = _media_status(props, record["file_path"], file_exists=file_exists)
10650
10846
  record["source_clip"], record["source_clip_reason"] = _source_clip_status(record, props)
10651
10847
  record["analyzable"], record["analyzable_reason"] = _analyzable_clip_status(record, props)
10652
10848
  return record
@@ -10786,77 +10982,36 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
10786
10982
  return status_by_key
10787
10983
 
10788
10984
 
10789
- def resolve_media_inventory(project_root: str, *, limit: Any = 500, recursive: bool = True) -> Dict[str, Any]:
10790
- try:
10791
- max_items = max(1, min(int(limit), 2000))
10792
- except (TypeError, ValueError):
10793
- max_items = 500
10794
- resolve, resolve_error = _connect_resolve_read_only()
10795
- if resolve_error:
10796
- return {
10797
- "success": True,
10798
- "resolve_available": False,
10799
- "status": "Resolve unavailable",
10800
- "error": resolve_error,
10801
- "clips": [],
10802
- "counts": _empty_media_counts(),
10803
- }
10804
- pm, pm_error = _safe_call(resolve, "GetProjectManager")
10805
- project = None
10806
- if pm and not pm_error:
10807
- project, _ = _safe_call(pm, "GetCurrentProject")
10808
- if not project:
10809
- return {
10810
- "success": True,
10811
- "resolve_available": False,
10812
- "status": "No Resolve project",
10813
- "error": "DaVinci Resolve is connected, but no project is open.",
10814
- "clips": [],
10815
- "counts": _empty_media_counts(),
10816
- }
10817
- media_pool, mp_error = _safe_call(project, "GetMediaPool")
10818
- if not media_pool or mp_error:
10819
- return {
10820
- "success": True,
10821
- "resolve_available": False,
10822
- "status": "Media Pool unavailable",
10823
- "error": mp_error or "Failed to get Resolve Media Pool",
10824
- "clips": [],
10825
- "counts": _empty_media_counts(),
10826
- }
10827
- root_folder, root_error = _safe_call(media_pool, "GetRootFolder")
10828
- if not root_folder or root_error:
10829
- return {
10830
- "success": True,
10831
- "resolve_available": False,
10832
- "status": "Root folder unavailable",
10833
- "error": root_error or "Failed to get Resolve root folder",
10834
- "clips": [],
10835
- "counts": _empty_media_counts(),
10836
- }
10985
+ # Last full Resolve walk per project_root, kept overlay-free so the analysis
10986
+ # status can be re-applied cheaply on every background poll without re-walking
10987
+ # the Media Pool (the expensive, non-parallelizable GetClipProperty pass).
10988
+ _INVENTORY_CACHE: Dict[str, Dict[str, Any]] = {}
10989
+ _INVENTORY_LOCK = threading.Lock()
10837
10990
 
10838
- selected_ids = set()
10839
- selected_clips, _ = _safe_call(media_pool, "GetSelectedClips")
10840
- for clip in selected_clips or []:
10841
- clip_id = _safe_id(clip)
10842
- if clip_id:
10843
- selected_ids.add(clip_id)
10844
-
10845
- warnings: List[str] = []
10846
- records: List[Dict[str, Any]] = []
10847
- truncated = _append_folder_media(
10848
- root_folder,
10849
- bin_path="Master",
10850
- recursive=recursive,
10851
- selected_ids=selected_ids,
10852
- records=records,
10853
- warnings=warnings,
10854
- limit=max_items,
10855
- )
10991
+
10992
+ def _get_cached_inventory(project_root: str) -> Optional[Dict[str, Any]]:
10993
+ with _INVENTORY_LOCK:
10994
+ return _INVENTORY_CACHE.get(project_root)
10995
+
10996
+
10997
+ def _store_cached_inventory(project_root: str, entry: Dict[str, Any]) -> None:
10998
+ with _INVENTORY_LOCK:
10999
+ _INVENTORY_CACHE[project_root] = entry
11000
+
11001
+
11002
+ def _assemble_inventory_payload(project_root: str, entry: Dict[str, Any]) -> Dict[str, Any]:
11003
+ """Apply the (local, cheap) analysis-status overlay onto cached base records.
11004
+
11005
+ Base records hold the Resolve-derived fields plus file existence; the analysis
11006
+ overlay (queued/running/analyzed, report paths, job ids) is re-read from disk
11007
+ every call so a background poll reflects job progress without touching Resolve.
11008
+ Records are copied so the cached base stays overlay-free across polls.
11009
+ """
11010
+ records = [dict(record) for record in entry["base_records"]]
10856
11011
  status_by_key = _analysis_status_by_clip(project_root, records)
10857
11012
  counts = _empty_media_counts()
10858
11013
  counts["total"] = len(records)
10859
- counts["selected"] = len(selected_ids)
11014
+ counts["selected"] = entry.get("selected_count", sum(1 for r in records if r.get("selected")))
10860
11015
  for record in records:
10861
11016
  status = record.get("status") or "unknown"
10862
11017
  if status in counts:
@@ -10879,17 +11034,140 @@ def resolve_media_inventory(project_root: str, *, limit: Any = 500, recursive: b
10879
11034
  "success": True,
10880
11035
  "resolve_available": True,
10881
11036
  "status": "Resolve connected",
10882
- "project": {
10883
- "name": _safe_name(project, "Resolve Project"),
10884
- "id": _safe_id(project),
10885
- },
11037
+ "project": entry["project"],
10886
11038
  "project_root": project_root,
10887
11039
  "clips": records,
10888
11040
  "counts": counts,
11041
+ "truncated": bool(entry.get("truncated")),
11042
+ "limit": entry.get("limit"),
11043
+ "warnings": entry.get("warnings", []),
11044
+ }
11045
+
11046
+
11047
+ def resolve_media_inventory(
11048
+ project_root: str,
11049
+ *,
11050
+ limit: Any = 500,
11051
+ recursive: bool = True,
11052
+ probe_paths: bool = True,
11053
+ reuse_cached: bool = False,
11054
+ ) -> Dict[str, Any]:
11055
+ try:
11056
+ max_items = max(1, min(int(limit), 2000))
11057
+ except (TypeError, ValueError):
11058
+ max_items = 500
11059
+
11060
+ # Background polls only need to surface analysis progress (a local, disk-backed
11061
+ # signal), so they reuse the last Resolve walk instead of paying for ~N serial
11062
+ # GetClipProperty round-trips again. A cheap project-id check still catches a
11063
+ # project switch made directly in Resolve (a handful of API calls vs a full
11064
+ # walk); we rebuild only on a confirmed mismatch. If the current project can't
11065
+ # be determined (Resolve down / no project open), we keep serving the cache —
11066
+ # a transient blip shouldn't trigger an expensive rebuild on every poll.
11067
+ if reuse_cached:
11068
+ cached = _get_cached_inventory(project_root)
11069
+ if cached is not None:
11070
+ current_id, id_error = _current_resolve_project_id()
11071
+ cached_id = (cached.get("project") or {}).get("id")
11072
+ project_changed = (
11073
+ id_error is None
11074
+ and current_id is not None
11075
+ and str(current_id) != str(cached_id)
11076
+ )
11077
+ if not project_changed:
11078
+ return _assemble_inventory_payload(project_root, cached)
11079
+
11080
+ # Everything that touches the Resolve scripting API stays under the lock; the
11081
+ # parallel path probe and the disk overlay run outside it.
11082
+ with _RESOLVE_API_LOCK:
11083
+ resolve, resolve_error = _connect_resolve_read_only()
11084
+ if resolve_error:
11085
+ return {
11086
+ "success": True,
11087
+ "resolve_available": False,
11088
+ "status": "Resolve unavailable",
11089
+ "error": resolve_error,
11090
+ "clips": [],
11091
+ "counts": _empty_media_counts(),
11092
+ }
11093
+ pm, pm_error = _safe_call(resolve, "GetProjectManager")
11094
+ project = None
11095
+ if pm and not pm_error:
11096
+ project, _ = _safe_call(pm, "GetCurrentProject")
11097
+ if not project:
11098
+ return {
11099
+ "success": True,
11100
+ "resolve_available": False,
11101
+ "status": "No Resolve project",
11102
+ "error": "DaVinci Resolve is connected, but no project is open.",
11103
+ "clips": [],
11104
+ "counts": _empty_media_counts(),
11105
+ }
11106
+ media_pool, mp_error = _safe_call(project, "GetMediaPool")
11107
+ if not media_pool or mp_error:
11108
+ return {
11109
+ "success": True,
11110
+ "resolve_available": False,
11111
+ "status": "Media Pool unavailable",
11112
+ "error": mp_error or "Failed to get Resolve Media Pool",
11113
+ "clips": [],
11114
+ "counts": _empty_media_counts(),
11115
+ }
11116
+ root_folder, root_error = _safe_call(media_pool, "GetRootFolder")
11117
+ if not root_folder or root_error:
11118
+ return {
11119
+ "success": True,
11120
+ "resolve_available": False,
11121
+ "status": "Root folder unavailable",
11122
+ "error": root_error or "Failed to get Resolve root folder",
11123
+ "clips": [],
11124
+ "counts": _empty_media_counts(),
11125
+ }
11126
+
11127
+ selected_ids = set()
11128
+ selected_clips, _ = _safe_call(media_pool, "GetSelectedClips")
11129
+ for clip in selected_clips or []:
11130
+ clip_id = _safe_id(clip)
11131
+ if clip_id:
11132
+ selected_ids.add(clip_id)
11133
+
11134
+ warnings: List[str] = []
11135
+ records: List[Dict[str, Any]] = []
11136
+ truncated = _append_folder_media(
11137
+ root_folder,
11138
+ bin_path="Master",
11139
+ recursive=recursive,
11140
+ selected_ids=selected_ids,
11141
+ records=records,
11142
+ warnings=warnings,
11143
+ limit=max_items,
11144
+ )
11145
+ project_info = {
11146
+ "name": _safe_name(project, "Resolve Project"),
11147
+ "id": _safe_id(project),
11148
+ }
11149
+ selected_count = len(selected_ids)
11150
+
11151
+ # Resolve every clip's file path in one parallel, cache-backed batch, then
11152
+ # finalize existence-dependent fields (status / analyzable).
11153
+ existence = _probe_paths_exist(
11154
+ (record.get("file_path") for record in records),
11155
+ probe=probe_paths,
11156
+ )
11157
+ for record in records:
11158
+ file_path = record.get("file_path")
11159
+ _finalize_clip_record(record, bool(file_path) and existence.get(str(file_path), False))
11160
+
11161
+ entry = {
11162
+ "base_records": records,
11163
+ "project": project_info,
11164
+ "selected_count": selected_count,
10889
11165
  "truncated": bool(truncated),
10890
11166
  "limit": max_items,
10891
11167
  "warnings": warnings,
10892
11168
  }
11169
+ _store_cached_inventory(project_root, entry)
11170
+ return _assemble_inventory_payload(project_root, entry)
10893
11171
 
10894
11172
 
10895
11173
  _PROJECT_CONTEXT_RE = re.compile(r"^(?P<slug>.+)-(?P<hash>[0-9a-f]{10})$")
@@ -10941,6 +11219,7 @@ def _context_from_project_root(base_root: str, project_root: str, *, source: str
10941
11219
  }
10942
11220
 
10943
11221
 
11222
+ @_serialize_resolve
10944
11223
  def _current_resolve_project_context(base_root: str) -> Optional[Dict[str, Any]]:
10945
11224
  resolve, resolve_error = _connect_resolve_read_only()
10946
11225
  if resolve_error:
@@ -11001,6 +11280,7 @@ def _project_folder_label(folder_path: List[str]) -> str:
11001
11280
  return " / ".join(folder_path) if folder_path else "Root"
11002
11281
 
11003
11282
 
11283
+ @_serialize_resolve
11004
11284
  def _resolve_all_project_contexts(base_root: str, *, max_depth: int = 12, max_projects: int = 2000) -> Dict[str, Any]:
11005
11285
  resolve, resolve_error = _connect_resolve_read_only()
11006
11286
  if resolve_error:
@@ -11123,6 +11403,7 @@ def _resolve_all_project_contexts(base_root: str, *, max_depth: int = 12, max_pr
11123
11403
  }
11124
11404
 
11125
11405
 
11406
+ @_serialize_resolve
11126
11407
  def _resolve_project_contexts(base_root: str) -> Dict[str, Any]:
11127
11408
  resolve, resolve_error = _connect_resolve_read_only()
11128
11409
  if resolve_error:
@@ -11196,6 +11477,7 @@ def _resolve_project_contexts(base_root: str) -> Dict[str, Any]:
11196
11477
  }
11197
11478
 
11198
11479
 
11480
+ @_serialize_resolve
11199
11481
  def _load_resolve_project_context(base_root: str, project_name: Any, folder_path: Any = None) -> Dict[str, Any]:
11200
11482
  target_name = str(project_name or "").strip()
11201
11483
  if not target_name:
@@ -12960,6 +13242,27 @@ class Handler(BaseHTTPRequestHandler):
12960
13242
  self.end_headers()
12961
13243
  self.wfile.write(raw)
12962
13244
 
13245
+ def _json_etag(self, payload: Dict[str, Any]) -> None:
13246
+ """JSON response with an ETag so unchanged polls short-circuit to 304.
13247
+
13248
+ The Resolve media inventory is re-fetched every few seconds; when the
13249
+ serialized payload is byte-identical to what the client already holds we
13250
+ skip both the body transfer and the client-side re-render of the table.
13251
+ """
13252
+ raw = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
13253
+ etag = '"' + hashlib.md5(raw).hexdigest() + '"'
13254
+ if self.headers.get("If-None-Match") == etag:
13255
+ self.send_response(HTTPStatus.NOT_MODIFIED)
13256
+ self.send_header("ETag", etag)
13257
+ self.end_headers()
13258
+ return
13259
+ self.send_response(200)
13260
+ self.send_header("Content-Type", "application/json; charset=utf-8")
13261
+ self.send_header("Content-Length", str(len(raw)))
13262
+ self.send_header("ETag", etag)
13263
+ self.end_headers()
13264
+ self.wfile.write(raw)
13265
+
12963
13266
  def _serve_file(self, path: str, content_type: str = "application/octet-stream") -> None:
12964
13267
  try:
12965
13268
  with open(path, "rb") as handle:
@@ -13089,11 +13392,13 @@ class Handler(BaseHTTPRequestHandler):
13089
13392
  self._json(_setup_defaults("get_defaults"))
13090
13393
  return
13091
13394
  if path == "/api/resolve/media":
13092
- self._json(
13395
+ self._json_etag(
13093
13396
  resolve_media_inventory(
13094
13397
  self.state.project_root,
13095
13398
  limit=(query.get("limit") or [500])[0],
13096
13399
  recursive=(query.get("recursive") or ["true"])[0].lower() not in {"0", "false", "no"},
13400
+ probe_paths=(query.get("probe") or ["1"])[0].lower() not in {"0", "false", "no"},
13401
+ reuse_cached=(query.get("reuse") or ["0"])[0].lower() in {"1", "true", "yes"},
13097
13402
  )
13098
13403
  )
13099
13404
  return
@@ -13556,6 +13861,20 @@ def parse_args() -> argparse.Namespace:
13556
13861
  return parser.parse_args()
13557
13862
 
13558
13863
 
13864
+ def _warm_inventory_cache(project_root: str) -> None:
13865
+ """Build the first Resolve inventory in the background at startup.
13866
+
13867
+ Populates the inventory + path-existence caches before the browser connects so
13868
+ the first dashboard open paints live data immediately instead of waiting on a
13869
+ cold Media Pool walk. Best-effort: if Resolve isn't up yet this no-ops and the
13870
+ first real request builds normally.
13871
+ """
13872
+ try:
13873
+ resolve_media_inventory(project_root)
13874
+ except Exception: # noqa: BLE001 — warm-up must never crash startup
13875
+ pass
13876
+
13877
+
13559
13878
  def main() -> None:
13560
13879
  args = parse_args()
13561
13880
  state = DashboardState(args.project_name, args.project_id, args.analysis_root)
@@ -13564,6 +13883,7 @@ def main() -> None:
13564
13883
  url = f"http://{args.host}:{args.port}"
13565
13884
  print(f"DaVinci Resolve MCP: {url}")
13566
13885
  print(f"Project analysis root: {state.project_root}")
13886
+ threading.Thread(target=_warm_inventory_cache, args=(state.project_root,), daemon=True).start()
13567
13887
  if args.open:
13568
13888
  webbrowser.open(url)
13569
13889
  try:
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.27.0"
83
+ VERSION = "2.27.1"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 329-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.27.0"
14
+ VERSION = "2.27.1"
15
15
 
16
16
  import base64
17
17
  import os