davinci-resolve-mcp 2.27.0 → 2.27.2

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,77 @@
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.2
6
+
7
+ **Control panel under-counted analyzed clips after a Media Pool rename (issue
8
+ #51)** — with every clip analyzed (e.g. 303/303 reports on disk), the overview
9
+ and Media tab could report something like "108 / 303 analyzed". The panel only
10
+ recognized a report when a folder's name exactly matched the clip's *current*
11
+ display name, so renaming clips after analysis hid their existing reports even
12
+ though the underlying media was unchanged.
13
+
14
+ Root cause and fix:
15
+
16
+ - **Lookups are keyed by a rename-stable hash, not the display-name folder.**
17
+ Report folders are named `<display-slug>-<hash>`; the count now matches on the
18
+ trailing hash (and the ids inside each report), so a renamed clip still
19
+ resolves to its existing folder. Both the disk path and the jobs-DB fallback
20
+ were corrected.
21
+ - **The hash is now anchored to the normalized file path (canonical basis).**
22
+ Previously the basis was a `clip_id`-first cascade, so the same media hashed
23
+ differently depending on which fields a record carried — Resolve inventory
24
+ (clip_id) vs path-based batch jobs (file path) disagreed on the same clip.
25
+ Anchoring to the file path removes that cross-basis mismatch. Legacy folders
26
+ (clip_id-based, or raw-path-based) still resolve via a migration-safe set of
27
+ candidate hashes, so **no on-disk migration is required**.
28
+ - **Writes reuse an existing report folder** (matched by canonical or legacy
29
+ hash) instead of minting a new `<newslug>-<hash>` directory, eliminating
30
+ orphaned duplicate folders when a renamed clip is re-analyzed.
31
+ - **A persisted clip index (`clips/index.json`)** maps every stable id found in
32
+ a report (normalized + raw file path, clip_id, media_id) to its folder, so the
33
+ count can still match a clip by any id it carries — including an offline clip
34
+ that no longer reports a file path but retains its clip_id. The index is
35
+ refreshed only when a report is added, removed, or rewritten (cheap signature
36
+ check), so the recurring poll stays inexpensive.
37
+
38
+ No public MCP tool surface changed. Adds regression tests in
39
+ `tests/test_media_analysis.py` covering rename, cross-basis, legacy-folder reuse,
40
+ the jobs-DB fallback, and the offline/no-path case.
41
+
42
+ ## What's New in v2.27.1
43
+
44
+ **Faster control-panel startup with network source media (issue #50)** — on
45
+ first open the control panel could sit on "connection pending" for a long time
46
+ when Media Pool clips lived on mounted network storage, because the UI only
47
+ treated the connection as live once the full media inventory finished loading,
48
+ and that inventory probed every clip's file path on disk.
49
+
50
+ Fixes and performance work:
51
+
52
+ - **Connection state is decoupled from the media inventory.** The overview and
53
+ diagnostics panels now derive "connected" from the `/api/boot` handshake (which
54
+ returns as soon as the Resolve bridge is reachable) and show inventory loading
55
+ separately, so Resolve reads as live immediately while clips stream in.
56
+ - **Parallel, cached file-existence probing.** `os.path.exists` for every clip
57
+ now runs in a thread pool and is memoized for a short TTL, instead of two serial
58
+ `stat()` calls per clip — the dominant cost on network storage.
59
+ - **Background polls reuse the cached Media Pool walk.** The recurring poll no
60
+ longer re-runs the ~N serial `GetClipProperty` calls; it reuses the last walk
61
+ and re-applies only the local, disk-backed analysis-status overlay. A cheap
62
+ project-id check still catches a project switched directly in Resolve, and a
63
+ manual refresh always does a full walk.
64
+ - **Resolve scripting API access is serialized.** A re-entrant lock guards every
65
+ scripting-API entry point, since the dashboard's threaded HTTP server could
66
+ previously fire concurrent (thread-unsafe) Resolve calls at startup.
67
+ - **ETag/304 on the inventory endpoint** skips transfer and table re-render when
68
+ nothing changed; the last good inventory is cached client-side and painted
69
+ instantly on reload; and the first inventory build is warmed in a background
70
+ thread at server start.
71
+
72
+ No public MCP tool surface changed. Adds regression tests in
73
+ `tests/test_media_analysis.py` (path-existence probing, inventory cache reuse,
74
+ project-switch detection, lock reentrancy).
75
+
5
76
  ## What's New in v2.27.0
6
77
 
7
78
  **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.2-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.2"
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.2",
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
@@ -23,7 +27,10 @@ from src.utils.media_analysis import (
23
27
  detect_capabilities,
24
28
  query_analysis_index,
25
29
  resolve_output_root,
30
+ clip_directory_hash,
31
+ load_clip_index,
26
32
  stable_clip_directory,
33
+ stable_clip_match_hashes,
27
34
  )
28
35
  from src.utils.media_analysis_jobs import (
29
36
  MEDIA_EXTENSIONS,
@@ -4636,6 +4643,8 @@ HTML = r"""<!doctype html>
4636
4643
  allProjects: null,
4637
4644
  activeContext: null,
4638
4645
  resolveMedia: null,
4646
+ resolveMediaStale: false,
4647
+ mediaETag: null,
4639
4648
  mediaPollTimer: null,
4640
4649
  mediaRefreshing: false,
4641
4650
  mediaLastRefresh: null,
@@ -5032,18 +5041,34 @@ HTML = r"""<!doctype html>
5032
5041
  const clips = sourceClips();
5033
5042
  const hiddenRecords = Math.max(0, Number(counts.total || 0) - Number(counts.source_clips || clips.length));
5034
5043
  const sequences = sequenceCount(media);
5044
+ // Connection state comes from the /api/boot handshake, which returns as soon
5045
+ // as the Resolve bridge is reachable — independent of the media inventory,
5046
+ // which can take a long time to probe network source media. inventoryPending
5047
+ // means Resolve is live but /api/resolve/media hasn't returned yet.
5048
+ const bootResolve = state.boot?.resolve || {};
5049
+ const resolveConnected = bootResolve.available === true || !!media?.resolve_available;
5050
+ const inventoryPending = resolveConnected && !media;
5035
5051
  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');
5052
+ const resolveProject = media?.project?.name || (resolveConnected ? 'Loading project…' : 'No Resolve project');
5053
+ let resolveStatus;
5054
+ if (media?.resolve_available) {
5055
+ resolveStatus = `Resolve: ${resolveProject} · read-only`;
5056
+ } else if (inventoryPending) {
5057
+ resolveStatus = 'Resolve connected · loading inventory…';
5058
+ } else if (resolveConnected) {
5059
+ resolveStatus = media?.status || media?.error || 'Resolve connected';
5060
+ } else {
5061
+ resolveStatus = media?.status || bootResolve.error || 'Connection pending';
5062
+ }
5038
5063
  const index = indexSummary();
5039
5064
  const readyClips = clips.filter(clip => clip.analyzable).length;
5040
5065
  const analyzedClips = clips.filter(clip => ['analyzed', 'succeeded', 'skipped'].includes(String(clip.analysis_status || ''))).length;
5041
5066
  const onlineClips = clips.filter(clip => String(clip.status || '') === 'online').length;
5042
5067
  const missingClips = clips.filter(clip => ['missing_file', 'offline'].includes(String(clip.status || ''))).length;
5043
- const mediaStatusLabel = media?.resolve_available ? `${onlineClips} online` : 'Unavailable';
5068
+ const mediaStatusLabel = media?.resolve_available ? `${onlineClips} online` : (inventoryPending ? 'Loading…' : 'Unavailable');
5044
5069
  const mediaStatusDetail = media?.resolve_available
5045
5070
  ? `${missingClips} missing/offline · ${hiddenRecords} non-source records`
5046
- : (media?.error || media?.status || 'Resolve inventory pending');
5071
+ : (inventoryPending ? 'Reading Media Pool inventory…' : (media?.error || media?.status || 'Resolve inventory pending'));
5047
5072
 
5048
5073
  setText('overviewUpdated', `Updated ${new Date().toLocaleTimeString()}`);
5049
5074
  setText('overviewProject', projectName);
@@ -5056,7 +5081,10 @@ HTML = r"""<!doctype html>
5056
5081
  setText('overviewMediaStatusDetail', mediaStatusDetail);
5057
5082
 
5058
5083
  if (!media?.resolve_available) {
5059
- setHtml('overviewStatusList', `<div class="empty">${escapeHtml(media?.error || 'Open Resolve with a project loaded to inspect clips.')}</div>`);
5084
+ const emptyMsg = inventoryPending
5085
+ ? 'Resolve connected — loading Media Pool inventory…'
5086
+ : (media?.error || 'Open Resolve with a project loaded to inspect clips.');
5087
+ setHtml('overviewStatusList', `<div class="empty">${escapeHtml(emptyMsg)}</div>`);
5060
5088
  } else {
5061
5089
  const ICONS = {
5062
5090
  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 +5137,13 @@ HTML = r"""<!doctype html>
5109
5137
  const index = indexSummary();
5110
5138
  const resolveInfo = state.boot?.resolve || {};
5111
5139
  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');
5140
+ // The boot handshake establishes the connection; media inventory loads after.
5141
+ const handshake = resolveInfo.available === true;
5142
+ const resolveOnline = !!media?.resolve_available || handshake;
5143
+ const inventoryPending = handshake && !media;
5144
+ const offline = (media || resolveInfo.error) && !resolveOnline;
5145
+ const resolveTone = resolveOnline ? 'pill-ok' : (offline ? 'pill-err' : 'pill-mute');
5146
+ const resolveLabel = resolveOnline ? 'Connected' : (offline ? 'Offline' : 'Pending');
5115
5147
 
5116
5148
  const connectionCard = `
5117
5149
  <div class="diag-card">
@@ -5123,7 +5155,7 @@ HTML = r"""<!doctype html>
5123
5155
  ${diagRow('Product', resolveInfo.product || (resolveOnline ? 'DaVinci Resolve' : 'Unavailable'), { muted: !resolveOnline })}
5124
5156
  ${diagRow('Version', resolveInfo.version_string || '—', { muted: !resolveInfo.version_string })}
5125
5157
  ${diagRow('Active page', resolveInfo.page || '—', { muted: !resolveInfo.page })}
5126
- ${diagRow('Status', resolveOnline ? 'Read-only API live' : (media?.error || media?.status || 'Waiting for handshake'))}
5158
+ ${diagRow('Status', inventoryPending ? 'Read-only API live · loading inventory…' : (resolveOnline ? 'Read-only API live' : (media?.error || resolveInfo.error || media?.status || 'Waiting for handshake')))}
5127
5159
  </div>
5128
5160
  </div>`;
5129
5161
 
@@ -5926,6 +5958,14 @@ HTML = r"""<!doctype html>
5926
5958
  syncPreferencesPanel();
5927
5959
  applyPreferencesToControls(prefs);
5928
5960
  renderVersionBadge();
5961
+ // Paint the previous inventory immediately (stale) so a slow first
5962
+ // /api/resolve/media — common with network source media — doesn't leave the
5963
+ // panel blank. The live fetch below replaces it and clears the stale flag.
5964
+ const cachedInventory = loadInventorySnapshot();
5965
+ if (cachedInventory) {
5966
+ state.resolveMediaStale = true;
5967
+ renderResolveMedia(cachedInventory);
5968
+ }
5929
5969
  await refreshProjectContexts();
5930
5970
  renderControlPanels();
5931
5971
  await refreshIndex();
@@ -6057,15 +6097,56 @@ HTML = r"""<!doctype html>
6057
6097
  state.mediaRefreshing = true;
6058
6098
  updateMediaPollStatus('refreshing');
6059
6099
  try {
6060
- const payload = await api('/api/resolve/media?limit=500');
6100
+ // Background polls reuse the cached Resolve walk and skip the network FS
6101
+ // probe (the server re-applies only the local analysis overlay); manual /
6102
+ // first loads do a full Media Pool walk with a fresh probe. The ETag lets
6103
+ // an unchanged poll short-circuit to 304 and skip the table re-render.
6104
+ const query = options.silent ? '&probe=0&reuse=1' : '';
6105
+ const headers = {};
6106
+ if (state.mediaETag) headers['If-None-Match'] = state.mediaETag;
6107
+ const res = await fetch(`/api/resolve/media?limit=500${query}`, { headers, cache: 'no-store' });
6061
6108
  state.mediaLastRefresh = new Date();
6109
+ state.resolveMediaStale = false;
6110
+ if (res.status === 304) return;
6111
+ const payload = await res.json();
6112
+ if (!res.ok || payload.success === false) {
6113
+ throw new Error(payload.error || res.statusText);
6114
+ }
6115
+ state.mediaETag = res.headers.get('ETag') || state.mediaETag;
6062
6116
  renderResolveMedia(payload);
6117
+ saveInventorySnapshot(payload);
6063
6118
  } finally {
6064
6119
  state.mediaRefreshing = false;
6065
6120
  updateMediaPollStatus();
6066
6121
  }
6067
6122
  }
6068
6123
 
6124
+ // Persist the last good inventory so reopening the dashboard paints the
6125
+ // previous snapshot instantly (with a "refreshing" hint) instead of sitting
6126
+ // on "connection pending" until the first fetch returns.
6127
+ function inventorySnapshotKey() {
6128
+ return 'resolveMcpInventory:' + (state.activeContext?.project_root || state.boot?.project_root || state.boot?.project_name || 'default');
6129
+ }
6130
+ function saveInventorySnapshot(payload) {
6131
+ if (!payload?.resolve_available) return;
6132
+ try {
6133
+ localStorage.setItem(inventorySnapshotKey(), JSON.stringify({ saved_at: Date.now(), payload }));
6134
+ } catch (error) {
6135
+ // Quota or serialization failure is non-fatal — the snapshot is a nicety.
6136
+ console.warn('Could not cache inventory snapshot', error);
6137
+ }
6138
+ }
6139
+ function loadInventorySnapshot() {
6140
+ try {
6141
+ const raw = localStorage.getItem(inventorySnapshotKey());
6142
+ if (!raw) return null;
6143
+ const parsed = JSON.parse(raw);
6144
+ return parsed?.payload?.resolve_available ? parsed.payload : null;
6145
+ } catch (error) {
6146
+ return null;
6147
+ }
6148
+ }
6149
+
6069
6150
  function promptCandidateClips() {
6070
6151
  return filteredResolveClips(state.resolveMedia?.clips || [])
6071
6152
  .filter(clip => clip.source_clip && clip.analyzable && clip.clip_id);
@@ -6550,7 +6631,8 @@ HTML = r"""<!doctype html>
6550
6631
  const poll = enabled ? `polling every ${Math.round(interval / 1000)}s` : 'polling off';
6551
6632
  const visible = state.resolveMedia?.clips ? clipLabel(filteredResolveClips(state.resolveMedia.clips).length) : 'no media snapshot';
6552
6633
  const prefix = state.mediaRefreshing ? 'refreshing' : poll;
6553
- el.textContent = `${prefix} · last ${last} · ${visible}${extra ? ` · ${extra}` : ''}`;
6634
+ const stale = state.resolveMediaStale ? 'cached · ' : '';
6635
+ el.textContent = `${stale}${prefix} · last ${last} · ${visible}${extra ? ` · ${extra}` : ''}`;
6554
6636
  }
6555
6637
 
6556
6638
  function rerenderResolveMedia() {
@@ -10504,27 +10586,74 @@ def _safe_id(obj: Any) -> Optional[str]:
10504
10586
  return str(value) if value else None
10505
10587
 
10506
10588
 
10589
+ # ── Resolve scripting API serialization ─────────────────────────────────────
10590
+ # The dashboard runs on a ThreadingHTTPServer, so /api/boot, /api/projects and
10591
+ # /api/resolve/media can land on separate threads concurrently (especially at
10592
+ # startup). DaVinci's scripting API is not thread-safe, so every entry point that
10593
+ # talks to it acquires this re-entrant lock for the full duration of its calls.
10594
+ _RESOLVE_API_LOCK = threading.RLock()
10595
+ _RESOLVE_ENV_READY = False
10596
+
10597
+
10598
+ def _serialize_resolve(func):
10599
+ """Decorator: hold the Resolve API lock for the whole call."""
10600
+ @functools.wraps(func)
10601
+ def wrapper(*args, **kwargs):
10602
+ with _RESOLVE_API_LOCK:
10603
+ return func(*args, **kwargs)
10604
+ return wrapper
10605
+
10606
+
10507
10607
  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
10608
+ global _RESOLVE_ENV_READY
10609
+ with _RESOLVE_API_LOCK:
10610
+ # Environment + sys.path setup is pure overhead and never goes stale, so
10611
+ # run it once per process rather than on every connection.
10612
+ if not _RESOLVE_ENV_READY:
10613
+ try:
10614
+ setup_environment()
10615
+ modules_path = os.environ.get("RESOLVE_SCRIPT_API")
10616
+ if modules_path:
10617
+ candidate = os.path.join(modules_path, "Modules")
10618
+ if candidate not in sys.path:
10619
+ sys.path.append(candidate)
10620
+ _RESOLVE_ENV_READY = True
10621
+ except Exception as exc:
10622
+ return None, f"Resolve scripting API unavailable: {exc}"
10623
+ try:
10624
+ import DaVinciResolveScript as dvr_script # type: ignore
10625
+ except Exception as exc:
10626
+ return None, f"Resolve scripting API unavailable: {exc}"
10627
+ try:
10628
+ resolve = dvr_script.scriptapp("Resolve")
10629
+ except Exception as exc:
10630
+ return None, f"Resolve connection failed: {exc}"
10631
+ if resolve is None:
10632
+ return None, "DaVinci Resolve is not connected. Open Resolve Studio with a project loaded."
10633
+ return resolve, None
10634
+
10526
10635
 
10636
+ @_serialize_resolve
10637
+ def _current_resolve_project_id() -> Tuple[Optional[str], Optional[str]]:
10638
+ """(project_id, error) for the currently-open Resolve project.
10527
10639
 
10640
+ A handful of cheap API calls — used by the media-poll reuse path to detect
10641
+ when the user has switched projects in Resolve since the inventory was cached,
10642
+ without paying for a full Media Pool walk.
10643
+ """
10644
+ resolve, error = _connect_resolve_read_only()
10645
+ if error or resolve is None:
10646
+ return None, error or "Resolve unavailable"
10647
+ pm, pm_error = _safe_call(resolve, "GetProjectManager")
10648
+ if not pm or pm_error:
10649
+ return None, pm_error or "Project manager unavailable"
10650
+ project, _ = _safe_call(pm, "GetCurrentProject")
10651
+ if not project:
10652
+ return None, "No Resolve project open"
10653
+ return _safe_id(project), None
10654
+
10655
+
10656
+ @_serialize_resolve
10528
10657
  def _resolve_identity() -> Dict[str, Any]:
10529
10658
  resolve, error = _connect_resolve_read_only()
10530
10659
  if not resolve:
@@ -10555,7 +10684,64 @@ def _first_prop(props: Dict[str, Any], keys: Tuple[str, ...]) -> Any:
10555
10684
  return None
10556
10685
 
10557
10686
 
10558
- def _media_status(props: Dict[str, Any], file_path: Optional[str]) -> str:
10687
+ # ── File-existence probing ──────────────────────────────────────────────────
10688
+ # stat() calls on mounted network storage dominate inventory time (300+ source
10689
+ # clips on a Z:\ share can take tens of seconds serially). We probe paths in a
10690
+ # thread pool and memoize results for a short TTL so the recurring media poll
10691
+ # does not re-stat unchanged paths every few seconds.
10692
+ _PATH_EXISTS_TTL = 60.0
10693
+ _PATH_PROBE_WORKERS = 16
10694
+ _PATH_EXISTS_CACHE: Dict[str, Tuple[float, bool]] = {}
10695
+ _PATH_EXISTS_LOCK = threading.Lock()
10696
+
10697
+
10698
+ def _cached_path_exists(path: str, now: float, ttl: float) -> Optional[bool]:
10699
+ with _PATH_EXISTS_LOCK:
10700
+ entry = _PATH_EXISTS_CACHE.get(path)
10701
+ if entry is not None and (now - entry[0]) <= ttl:
10702
+ return entry[1]
10703
+ return None
10704
+
10705
+
10706
+ def _store_path_exists(path: str, exists: bool, now: float) -> None:
10707
+ with _PATH_EXISTS_LOCK:
10708
+ _PATH_EXISTS_CACHE[path] = (now, exists)
10709
+
10710
+
10711
+ def _probe_paths_exist(paths: Any, *, probe: bool = True, ttl: float = _PATH_EXISTS_TTL) -> Dict[str, bool]:
10712
+ """Resolve a collection of file paths to existence booleans.
10713
+
10714
+ With ``probe=True`` (first load / manual refresh) any cache entry older than
10715
+ ``ttl`` is re-stat'd, and uncached paths are probed in parallel. With
10716
+ ``probe=False`` (background poll) the filesystem is never touched: cached
10717
+ values are reused at any age and unknown paths fall back to ``True`` —
10718
+ Resolve's own online/offline Status property still flags clips it knows are
10719
+ missing, so we trust it rather than paying for a network round-trip on every
10720
+ poll.
10721
+ """
10722
+ distinct = {str(p) for p in paths if p}
10723
+ result: Dict[str, bool] = {}
10724
+ to_probe: List[str] = []
10725
+ now = time.time()
10726
+ lookup_ttl = ttl if probe else float("inf")
10727
+ for path in distinct:
10728
+ cached = _cached_path_exists(path, now, lookup_ttl)
10729
+ if cached is not None:
10730
+ result[path] = cached
10731
+ elif probe:
10732
+ to_probe.append(path)
10733
+ else:
10734
+ result[path] = True
10735
+ if to_probe:
10736
+ workers = max(1, min(_PATH_PROBE_WORKERS, len(to_probe)))
10737
+ with ThreadPoolExecutor(max_workers=workers) as pool:
10738
+ for path, exists in zip(to_probe, pool.map(os.path.exists, to_probe)):
10739
+ result[path] = bool(exists)
10740
+ _store_path_exists(path, bool(exists), now)
10741
+ return result
10742
+
10743
+
10744
+ def _media_status(props: Dict[str, Any], file_path: Optional[str], *, file_exists: Optional[bool] = None) -> str:
10559
10745
  status_text = str(_first_prop(props, ("Status", "Media Status", "Online Status", "Offline")) or "").strip().lower()
10560
10746
  if not file_path:
10561
10747
  return "no_path"
@@ -10563,7 +10749,11 @@ def _media_status(props: Dict[str, Any], file_path: Optional[str]) -> str:
10563
10749
  return "offline"
10564
10750
  if "missing" in status_text:
10565
10751
  return "missing_file"
10566
- if not os.path.exists(str(file_path)):
10752
+ # Pass `file_exists` in to reuse a single os.path.exists() probe — stat calls on
10753
+ # network source media are slow, so the caller avoids probing the same path twice.
10754
+ if file_exists is None:
10755
+ file_exists = os.path.exists(str(file_path))
10756
+ if not file_exists:
10567
10757
  return "missing_file"
10568
10758
  return "online"
10569
10759
 
@@ -10644,9 +10834,18 @@ def _resolve_clip_record(clip: Any, bin_path: str, selected_ids: set) -> Dict[st
10644
10834
  "resolve_status": _first_prop(props, ("Status", "Media Status", "Online Status", "Offline")),
10645
10835
  "selected": bool(clip_id and clip_id in selected_ids),
10646
10836
  }
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"])
10837
+ # File-existence is resolved in a single parallel batch after every clip's
10838
+ # Resolve properties are gathered (see resolve_media_inventory), so we stash
10839
+ # the props and defer existence-dependent fields to _finalize_clip_record.
10649
10840
  record["clip_key"] = stable_clip_directory(record)
10841
+ record["_props"] = props
10842
+ return record
10843
+
10844
+
10845
+ def _finalize_clip_record(record: Dict[str, Any], file_exists: bool) -> Dict[str, Any]:
10846
+ props = record.pop("_props", {}) or {}
10847
+ record["file_exists"] = file_exists
10848
+ record["status"] = _media_status(props, record["file_path"], file_exists=file_exists)
10650
10849
  record["source_clip"], record["source_clip_reason"] = _source_clip_status(record, props)
10651
10850
  record["analyzable"], record["analyzable_reason"] = _analyzable_clip_status(record, props)
10652
10851
  return record
@@ -10716,11 +10915,32 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
10716
10915
  status_by_key: Dict[str, Dict[str, Any]] = {}
10717
10916
  if not keys:
10718
10917
  return status_by_key
10918
+
10919
+ # Resolve each clip to its report via the persisted clip index, which maps
10920
+ # every stable id found in a report (normalized + raw file path, clip_id,
10921
+ # media_id) to its folder. This survives a Media Pool rename, a legacy hash
10922
+ # basis, AND an offline clip that no longer reports a file path but still
10923
+ # carries its clip_id — none of which a folder-name scan can match. See #51.
10924
+ clips_root = os.path.join(project_root, "clips")
10925
+ hash_to_folder = load_clip_index(project_root).get("hash_to_folder") or {}
10926
+
10719
10927
  for record in records:
10720
10928
  clip_key = record.get("clip_key")
10721
10929
  if not clip_key:
10722
10930
  continue
10723
10931
  report_path = os.path.join(project_root, "clips", str(clip_key), "analysis.json")
10932
+ if not os.path.isfile(report_path):
10933
+ # Renamed/legacy/offline clip: the recomputed clip_key no longer
10934
+ # matches the folder on disk. Fall back to any of the clip's stable
10935
+ # hashes via the index.
10936
+ for clip_hash in stable_clip_match_hashes(record):
10937
+ folder = hash_to_folder.get(clip_hash)
10938
+ if not folder:
10939
+ continue
10940
+ candidate = os.path.join(clips_root, folder, "analysis.json")
10941
+ if os.path.isfile(candidate):
10942
+ report_path = candidate
10943
+ break
10724
10944
  if os.path.isfile(report_path):
10725
10945
  status_by_key[str(clip_key)] = {
10726
10946
  "analysis_status": "analyzed",
@@ -10730,32 +10950,24 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
10730
10950
  db_path = os.path.join(project_root, "jobs.sqlite")
10731
10951
  if not os.path.isfile(db_path):
10732
10952
  return status_by_key
10733
- placeholders = ",".join("?" for _ in keys)
10734
- try:
10735
- conn = sqlite3.connect(db_path)
10736
- conn.row_factory = sqlite3.Row
10737
- rows = conn.execute(
10738
- f"""
10739
- SELECT jc.clip_key, jc.status, jc.cache_status, jc.report_path, jc.error,
10740
- j.job_id, j.name AS job_name, j.updated_at
10741
- FROM job_clips jc
10742
- JOIN jobs j ON j.job_id = jc.job_id
10743
- WHERE jc.clip_key IN ({placeholders})
10744
- ORDER BY jc.updated_at DESC
10745
- """,
10746
- keys,
10747
- ).fetchall()
10748
- except Exception:
10749
- return status_by_key
10750
- finally:
10751
- try:
10752
- conn.close()
10753
- except Exception:
10754
- pass
10755
- for row in rows:
10756
- clip_key = str(row["clip_key"])
10757
- if clip_key in status_by_key and status_by_key[clip_key].get("analysis_status") == "analyzed":
10953
+
10954
+ # The jobs DB stores each clip under the clip_key it had when analyzed. A
10955
+ # clip renamed afterwards produces a new clip_key, so an exact-key match
10956
+ # misses its job row. Index unresolved records by their rename-stable hash
10957
+ # so a DB row recorded under the old name (e.g. a reused batch report living
10958
+ # outside the local clips/ dir) still maps back to the current clip. #51.
10959
+ key_set = {str(k) for k in keys}
10960
+ pending_hash_to_key: Dict[str, str] = {}
10961
+ for record in records:
10962
+ clip_key = record.get("clip_key")
10963
+ if not clip_key or str(clip_key) in status_by_key:
10758
10964
  continue
10965
+ for folder_hash in stable_clip_match_hashes(record):
10966
+ pending_hash_to_key.setdefault(folder_hash, str(clip_key))
10967
+
10968
+ def _apply_row(row: sqlite3.Row, target_key: str) -> None:
10969
+ if status_by_key.get(target_key, {}).get("analysis_status") == "analyzed":
10970
+ return
10759
10971
  db_status = row["status"]
10760
10972
  report_path = row["report_path"]
10761
10973
  # In media_analysis_jobs, 'succeeded' = fresh analysis written this run,
@@ -10773,7 +10985,7 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
10773
10985
  normalized = db_status
10774
10986
  if db_status in ("succeeded", "skipped") and report_resolves:
10775
10987
  normalized = "analyzed"
10776
- status_by_key[clip_key] = {
10988
+ status_by_key[target_key] = {
10777
10989
  "analysis_status": normalized,
10778
10990
  "job_status": db_status,
10779
10991
  "cache_status": row["cache_status"],
@@ -10783,80 +10995,78 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
10783
10995
  "job_name": row["job_name"],
10784
10996
  "job_updated_at": row["updated_at"],
10785
10997
  }
10786
- return status_by_key
10787
-
10788
10998
 
10789
- def resolve_media_inventory(project_root: str, *, limit: Any = 500, recursive: bool = True) -> Dict[str, Any]:
10999
+ select_cols = (
11000
+ "SELECT jc.clip_key, jc.status, jc.cache_status, jc.report_path, jc.error, "
11001
+ "j.job_id, j.name AS job_name, j.updated_at "
11002
+ "FROM job_clips jc JOIN jobs j ON j.job_id = jc.job_id"
11003
+ )
11004
+ placeholders = ",".join("?" for _ in keys)
10790
11005
  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(),
11006
+ conn = sqlite3.connect(db_path)
11007
+ conn.row_factory = sqlite3.Row
11008
+ rows = conn.execute(
11009
+ f"{select_cols} WHERE jc.clip_key IN ({placeholders}) ORDER BY jc.updated_at DESC",
11010
+ keys,
11011
+ ).fetchall()
11012
+ for row in rows:
11013
+ _apply_row(row, str(row["clip_key"]))
11014
+ # Only pay for the unfiltered scan when a rename actually left a record
11015
+ # unresolved by the disk pass and exact-key match above.
11016
+ unresolved = {
11017
+ h: k for h, k in pending_hash_to_key.items() if k not in status_by_key
10836
11018
  }
11019
+ if unresolved:
11020
+ for row in conn.execute(
11021
+ f"{select_cols} ORDER BY jc.updated_at DESC"
11022
+ ).fetchall():
11023
+ raw_key = str(row["clip_key"])
11024
+ if raw_key in key_set:
11025
+ continue
11026
+ row_hash = clip_directory_hash(raw_key)
11027
+ target_key = unresolved.get(row_hash) if row_hash else None
11028
+ if target_key:
11029
+ _apply_row(row, target_key)
11030
+ except Exception:
11031
+ return status_by_key
11032
+ finally:
11033
+ try:
11034
+ conn.close()
11035
+ except Exception:
11036
+ pass
11037
+ return status_by_key
10837
11038
 
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
- )
11039
+
11040
+ # Last full Resolve walk per project_root, kept overlay-free so the analysis
11041
+ # status can be re-applied cheaply on every background poll without re-walking
11042
+ # the Media Pool (the expensive, non-parallelizable GetClipProperty pass).
11043
+ _INVENTORY_CACHE: Dict[str, Dict[str, Any]] = {}
11044
+ _INVENTORY_LOCK = threading.Lock()
11045
+
11046
+
11047
+ def _get_cached_inventory(project_root: str) -> Optional[Dict[str, Any]]:
11048
+ with _INVENTORY_LOCK:
11049
+ return _INVENTORY_CACHE.get(project_root)
11050
+
11051
+
11052
+ def _store_cached_inventory(project_root: str, entry: Dict[str, Any]) -> None:
11053
+ with _INVENTORY_LOCK:
11054
+ _INVENTORY_CACHE[project_root] = entry
11055
+
11056
+
11057
+ def _assemble_inventory_payload(project_root: str, entry: Dict[str, Any]) -> Dict[str, Any]:
11058
+ """Apply the (local, cheap) analysis-status overlay onto cached base records.
11059
+
11060
+ Base records hold the Resolve-derived fields plus file existence; the analysis
11061
+ overlay (queued/running/analyzed, report paths, job ids) is re-read from disk
11062
+ every call so a background poll reflects job progress without touching Resolve.
11063
+ Records are copied so the cached base stays overlay-free across polls.
11064
+ """
11065
+ records = [dict(record) for record in entry["base_records"]]
10856
11066
  status_by_key = _analysis_status_by_clip(project_root, records)
10857
11067
  counts = _empty_media_counts()
10858
11068
  counts["total"] = len(records)
10859
- counts["selected"] = len(selected_ids)
11069
+ counts["selected"] = entry.get("selected_count", sum(1 for r in records if r.get("selected")))
10860
11070
  for record in records:
10861
11071
  status = record.get("status") or "unknown"
10862
11072
  if status in counts:
@@ -10879,17 +11089,140 @@ def resolve_media_inventory(project_root: str, *, limit: Any = 500, recursive: b
10879
11089
  "success": True,
10880
11090
  "resolve_available": True,
10881
11091
  "status": "Resolve connected",
10882
- "project": {
10883
- "name": _safe_name(project, "Resolve Project"),
10884
- "id": _safe_id(project),
10885
- },
11092
+ "project": entry["project"],
10886
11093
  "project_root": project_root,
10887
11094
  "clips": records,
10888
11095
  "counts": counts,
11096
+ "truncated": bool(entry.get("truncated")),
11097
+ "limit": entry.get("limit"),
11098
+ "warnings": entry.get("warnings", []),
11099
+ }
11100
+
11101
+
11102
+ def resolve_media_inventory(
11103
+ project_root: str,
11104
+ *,
11105
+ limit: Any = 500,
11106
+ recursive: bool = True,
11107
+ probe_paths: bool = True,
11108
+ reuse_cached: bool = False,
11109
+ ) -> Dict[str, Any]:
11110
+ try:
11111
+ max_items = max(1, min(int(limit), 2000))
11112
+ except (TypeError, ValueError):
11113
+ max_items = 500
11114
+
11115
+ # Background polls only need to surface analysis progress (a local, disk-backed
11116
+ # signal), so they reuse the last Resolve walk instead of paying for ~N serial
11117
+ # GetClipProperty round-trips again. A cheap project-id check still catches a
11118
+ # project switch made directly in Resolve (a handful of API calls vs a full
11119
+ # walk); we rebuild only on a confirmed mismatch. If the current project can't
11120
+ # be determined (Resolve down / no project open), we keep serving the cache —
11121
+ # a transient blip shouldn't trigger an expensive rebuild on every poll.
11122
+ if reuse_cached:
11123
+ cached = _get_cached_inventory(project_root)
11124
+ if cached is not None:
11125
+ current_id, id_error = _current_resolve_project_id()
11126
+ cached_id = (cached.get("project") or {}).get("id")
11127
+ project_changed = (
11128
+ id_error is None
11129
+ and current_id is not None
11130
+ and str(current_id) != str(cached_id)
11131
+ )
11132
+ if not project_changed:
11133
+ return _assemble_inventory_payload(project_root, cached)
11134
+
11135
+ # Everything that touches the Resolve scripting API stays under the lock; the
11136
+ # parallel path probe and the disk overlay run outside it.
11137
+ with _RESOLVE_API_LOCK:
11138
+ resolve, resolve_error = _connect_resolve_read_only()
11139
+ if resolve_error:
11140
+ return {
11141
+ "success": True,
11142
+ "resolve_available": False,
11143
+ "status": "Resolve unavailable",
11144
+ "error": resolve_error,
11145
+ "clips": [],
11146
+ "counts": _empty_media_counts(),
11147
+ }
11148
+ pm, pm_error = _safe_call(resolve, "GetProjectManager")
11149
+ project = None
11150
+ if pm and not pm_error:
11151
+ project, _ = _safe_call(pm, "GetCurrentProject")
11152
+ if not project:
11153
+ return {
11154
+ "success": True,
11155
+ "resolve_available": False,
11156
+ "status": "No Resolve project",
11157
+ "error": "DaVinci Resolve is connected, but no project is open.",
11158
+ "clips": [],
11159
+ "counts": _empty_media_counts(),
11160
+ }
11161
+ media_pool, mp_error = _safe_call(project, "GetMediaPool")
11162
+ if not media_pool or mp_error:
11163
+ return {
11164
+ "success": True,
11165
+ "resolve_available": False,
11166
+ "status": "Media Pool unavailable",
11167
+ "error": mp_error or "Failed to get Resolve Media Pool",
11168
+ "clips": [],
11169
+ "counts": _empty_media_counts(),
11170
+ }
11171
+ root_folder, root_error = _safe_call(media_pool, "GetRootFolder")
11172
+ if not root_folder or root_error:
11173
+ return {
11174
+ "success": True,
11175
+ "resolve_available": False,
11176
+ "status": "Root folder unavailable",
11177
+ "error": root_error or "Failed to get Resolve root folder",
11178
+ "clips": [],
11179
+ "counts": _empty_media_counts(),
11180
+ }
11181
+
11182
+ selected_ids = set()
11183
+ selected_clips, _ = _safe_call(media_pool, "GetSelectedClips")
11184
+ for clip in selected_clips or []:
11185
+ clip_id = _safe_id(clip)
11186
+ if clip_id:
11187
+ selected_ids.add(clip_id)
11188
+
11189
+ warnings: List[str] = []
11190
+ records: List[Dict[str, Any]] = []
11191
+ truncated = _append_folder_media(
11192
+ root_folder,
11193
+ bin_path="Master",
11194
+ recursive=recursive,
11195
+ selected_ids=selected_ids,
11196
+ records=records,
11197
+ warnings=warnings,
11198
+ limit=max_items,
11199
+ )
11200
+ project_info = {
11201
+ "name": _safe_name(project, "Resolve Project"),
11202
+ "id": _safe_id(project),
11203
+ }
11204
+ selected_count = len(selected_ids)
11205
+
11206
+ # Resolve every clip's file path in one parallel, cache-backed batch, then
11207
+ # finalize existence-dependent fields (status / analyzable).
11208
+ existence = _probe_paths_exist(
11209
+ (record.get("file_path") for record in records),
11210
+ probe=probe_paths,
11211
+ )
11212
+ for record in records:
11213
+ file_path = record.get("file_path")
11214
+ _finalize_clip_record(record, bool(file_path) and existence.get(str(file_path), False))
11215
+
11216
+ entry = {
11217
+ "base_records": records,
11218
+ "project": project_info,
11219
+ "selected_count": selected_count,
10889
11220
  "truncated": bool(truncated),
10890
11221
  "limit": max_items,
10891
11222
  "warnings": warnings,
10892
11223
  }
11224
+ _store_cached_inventory(project_root, entry)
11225
+ return _assemble_inventory_payload(project_root, entry)
10893
11226
 
10894
11227
 
10895
11228
  _PROJECT_CONTEXT_RE = re.compile(r"^(?P<slug>.+)-(?P<hash>[0-9a-f]{10})$")
@@ -10941,6 +11274,7 @@ def _context_from_project_root(base_root: str, project_root: str, *, source: str
10941
11274
  }
10942
11275
 
10943
11276
 
11277
+ @_serialize_resolve
10944
11278
  def _current_resolve_project_context(base_root: str) -> Optional[Dict[str, Any]]:
10945
11279
  resolve, resolve_error = _connect_resolve_read_only()
10946
11280
  if resolve_error:
@@ -11001,6 +11335,7 @@ def _project_folder_label(folder_path: List[str]) -> str:
11001
11335
  return " / ".join(folder_path) if folder_path else "Root"
11002
11336
 
11003
11337
 
11338
+ @_serialize_resolve
11004
11339
  def _resolve_all_project_contexts(base_root: str, *, max_depth: int = 12, max_projects: int = 2000) -> Dict[str, Any]:
11005
11340
  resolve, resolve_error = _connect_resolve_read_only()
11006
11341
  if resolve_error:
@@ -11123,6 +11458,7 @@ def _resolve_all_project_contexts(base_root: str, *, max_depth: int = 12, max_pr
11123
11458
  }
11124
11459
 
11125
11460
 
11461
+ @_serialize_resolve
11126
11462
  def _resolve_project_contexts(base_root: str) -> Dict[str, Any]:
11127
11463
  resolve, resolve_error = _connect_resolve_read_only()
11128
11464
  if resolve_error:
@@ -11196,6 +11532,7 @@ def _resolve_project_contexts(base_root: str) -> Dict[str, Any]:
11196
11532
  }
11197
11533
 
11198
11534
 
11535
+ @_serialize_resolve
11199
11536
  def _load_resolve_project_context(base_root: str, project_name: Any, folder_path: Any = None) -> Dict[str, Any]:
11200
11537
  target_name = str(project_name or "").strip()
11201
11538
  if not target_name:
@@ -12960,6 +13297,27 @@ class Handler(BaseHTTPRequestHandler):
12960
13297
  self.end_headers()
12961
13298
  self.wfile.write(raw)
12962
13299
 
13300
+ def _json_etag(self, payload: Dict[str, Any]) -> None:
13301
+ """JSON response with an ETag so unchanged polls short-circuit to 304.
13302
+
13303
+ The Resolve media inventory is re-fetched every few seconds; when the
13304
+ serialized payload is byte-identical to what the client already holds we
13305
+ skip both the body transfer and the client-side re-render of the table.
13306
+ """
13307
+ raw = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
13308
+ etag = '"' + hashlib.md5(raw).hexdigest() + '"'
13309
+ if self.headers.get("If-None-Match") == etag:
13310
+ self.send_response(HTTPStatus.NOT_MODIFIED)
13311
+ self.send_header("ETag", etag)
13312
+ self.end_headers()
13313
+ return
13314
+ self.send_response(200)
13315
+ self.send_header("Content-Type", "application/json; charset=utf-8")
13316
+ self.send_header("Content-Length", str(len(raw)))
13317
+ self.send_header("ETag", etag)
13318
+ self.end_headers()
13319
+ self.wfile.write(raw)
13320
+
12963
13321
  def _serve_file(self, path: str, content_type: str = "application/octet-stream") -> None:
12964
13322
  try:
12965
13323
  with open(path, "rb") as handle:
@@ -13089,11 +13447,13 @@ class Handler(BaseHTTPRequestHandler):
13089
13447
  self._json(_setup_defaults("get_defaults"))
13090
13448
  return
13091
13449
  if path == "/api/resolve/media":
13092
- self._json(
13450
+ self._json_etag(
13093
13451
  resolve_media_inventory(
13094
13452
  self.state.project_root,
13095
13453
  limit=(query.get("limit") or [500])[0],
13096
13454
  recursive=(query.get("recursive") or ["true"])[0].lower() not in {"0", "false", "no"},
13455
+ probe_paths=(query.get("probe") or ["1"])[0].lower() not in {"0", "false", "no"},
13456
+ reuse_cached=(query.get("reuse") or ["0"])[0].lower() in {"1", "true", "yes"},
13097
13457
  )
13098
13458
  )
13099
13459
  return
@@ -13556,6 +13916,20 @@ def parse_args() -> argparse.Namespace:
13556
13916
  return parser.parse_args()
13557
13917
 
13558
13918
 
13919
+ def _warm_inventory_cache(project_root: str) -> None:
13920
+ """Build the first Resolve inventory in the background at startup.
13921
+
13922
+ Populates the inventory + path-existence caches before the browser connects so
13923
+ the first dashboard open paints live data immediately instead of waiting on a
13924
+ cold Media Pool walk. Best-effort: if Resolve isn't up yet this no-ops and the
13925
+ first real request builds normally.
13926
+ """
13927
+ try:
13928
+ resolve_media_inventory(project_root)
13929
+ except Exception: # noqa: BLE001 — warm-up must never crash startup
13930
+ pass
13931
+
13932
+
13559
13933
  def main() -> None:
13560
13934
  args = parse_args()
13561
13935
  state = DashboardState(args.project_name, args.project_id, args.analysis_root)
@@ -13564,6 +13938,7 @@ def main() -> None:
13564
13938
  url = f"http://{args.host}:{args.port}"
13565
13939
  print(f"DaVinci Resolve MCP: {url}")
13566
13940
  print(f"Project analysis root: {state.project_root}")
13941
+ threading.Thread(target=_warm_inventory_cache, args=(state.project_root,), daemon=True).start()
13567
13942
  if args.open:
13568
13943
  webbrowser.open(url)
13569
13944
  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.2"
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.2"
15
15
 
16
16
  import base64
17
17
  import os
@@ -739,16 +739,225 @@ def project_directory_name(project_name: Any, project_id: Any = None) -> str:
739
739
  return f"{slugify(project_name, 'project')}-{short_hash(basis)}"
740
740
 
741
741
 
742
- def stable_clip_directory(record: Dict[str, Any]) -> str:
743
- basis = (
742
+ def stable_clip_basis(record: Dict[str, Any]) -> str:
743
+ """Return the canonical rename-stable identity used to hash a report folder.
744
+
745
+ The canonical basis is the *normalized file path*: it is present on both
746
+ Resolve-derived and path-based batch records, it survives a Media Pool
747
+ rename, and a genuine relink to a different file is handled separately as a
748
+ superseded source. Resolve-internal ids (clip_id/media_id) are absent from
749
+ path-based records and not portable across project copies, so they are only
750
+ used when no file path is available; the display name is the last resort.
751
+
752
+ Folder *resolution* (matching an existing report) must tolerate the legacy
753
+ bases too — see :func:`stable_clip_match_hashes`.
754
+ """
755
+ file_path = record.get("file_path")
756
+ if file_path:
757
+ return normalize_path(file_path)
758
+ return str(
744
759
  record.get("clip_id")
745
760
  or record.get("media_id")
746
- or record.get("file_path")
747
761
  or record.get("clip_name")
748
762
  or "clip"
749
763
  )
764
+
765
+
766
+ def stable_clip_hash(record: Dict[str, Any]) -> str:
767
+ """Return the canonical 12-char hash that anchors a clip's report folder."""
768
+ return short_hash(stable_clip_basis(record), 12)
769
+
770
+
771
+ def stable_clip_match_hashes(record: Dict[str, Any]) -> List[str]:
772
+ """All folder hashes that could identify this clip's existing report.
773
+
774
+ Returns the canonical hash first, followed by legacy bases so reports
775
+ written before the canonical file-path scheme (clip_id-first, or a raw
776
+ un-normalized path) still resolve without an on-disk migration. The display
777
+ name is only used when nothing more unique is available, so two different
778
+ clips that merely share a name are never matched to the same report.
779
+ """
780
+ hashes: List[str] = []
781
+ seen: set = set()
782
+
783
+ def add(value: Any) -> None:
784
+ if not value:
785
+ return
786
+ digest = short_hash(value, 12)
787
+ if digest not in seen:
788
+ seen.add(digest)
789
+ hashes.append(digest)
790
+
791
+ file_path = record.get("file_path")
792
+ if file_path:
793
+ add(normalize_path(file_path)) # canonical
794
+ add(str(file_path)) # legacy: raw, un-normalized path
795
+ add(record.get("clip_id")) # legacy: clip_id-first scheme
796
+ add(record.get("media_id"))
797
+ if not hashes:
798
+ add(record.get("clip_name") or "clip")
799
+ return hashes
800
+
801
+
802
+ def clip_directory_hash(name: Any) -> Optional[str]:
803
+ """Extract the trailing stable hash from a clip report folder name.
804
+
805
+ Folder names are ``<label>-<hash>`` where ``<label>`` is the (rename-prone)
806
+ display slug and ``<hash>`` is :func:`stable_clip_hash`. A bare ``<hash>``
807
+ folder (no slug) is also accepted. Returns the hash, or ``None`` if the
808
+ trailing token is not a 12-char hex hash.
809
+ """
810
+ base = os.path.basename(str(name or "").rstrip("/\\"))
811
+ suffix = base.rsplit("-", 1)[-1]
812
+ if re.fullmatch(r"[0-9a-f]{12}", suffix):
813
+ return suffix
814
+ return None
815
+
816
+
817
+ def stable_clip_directory(record: Dict[str, Any]) -> str:
750
818
  label = slugify(record.get("clip_name") or Path(str(record.get("file_path") or "clip")).stem, "clip")
751
- return f"{label}-{short_hash(basis, 12)}"
819
+ return f"{label}-{stable_clip_hash(record)}"
820
+
821
+
822
+ def resolve_clip_directory(project_root: str, record: Dict[str, Any]) -> str:
823
+ """Return the report directory for a clip, reusing an existing one if found.
824
+
825
+ Writes go through here so a clip that was renamed, or analyzed under a legacy
826
+ hash basis (e.g. clip_id-first, or a path-based batch report), reuses its
827
+ existing folder instead of orphaning it under a freshly minted name. Matches
828
+ by canonical hash first, then any legacy hash; falls back to the canonical
829
+ new path when nothing exists yet.
830
+ """
831
+ clips_root = os.path.join(project_root, "clips")
832
+ # Fast path: the canonical folder already exists by exact name. This is the
833
+ # steady state (re-analysis of an already-canonical clip) and avoids a full
834
+ # directory scan per clip on a batch run.
835
+ canonical_dir = os.path.join(clips_root, stable_clip_directory(record))
836
+ if os.path.isdir(canonical_dir):
837
+ return normalize_path(canonical_dir)
838
+ match = stable_clip_match_hashes(record)
839
+ if match and os.path.isdir(clips_root):
840
+ canonical = match[0]
841
+ match_set = set(match)
842
+ legacy_hit: Optional[str] = None
843
+ try:
844
+ entries = sorted(os.listdir(clips_root))
845
+ except OSError:
846
+ entries = []
847
+ for entry in entries:
848
+ candidate = os.path.join(clips_root, entry)
849
+ if not os.path.isdir(candidate):
850
+ continue
851
+ folder_hash = clip_directory_hash(entry)
852
+ if not folder_hash:
853
+ continue
854
+ if folder_hash == canonical:
855
+ return normalize_path(candidate)
856
+ if folder_hash in match_set and legacy_hit is None:
857
+ legacy_hit = candidate
858
+ if legacy_hit:
859
+ return normalize_path(legacy_hit)
860
+ return normalize_path(os.path.join(clips_root, stable_clip_directory(record)))
861
+
862
+
863
+ CLIP_INDEX_SCHEMA_VERSION = 1
864
+
865
+
866
+ def clip_index_path(project_root: str) -> str:
867
+ """Path of the per-project clip index (a sidecar under clips/)."""
868
+ return os.path.join(project_root, "clips", "index.json")
869
+
870
+
871
+ def _clip_dir_signature(clips_root: str) -> str:
872
+ """Cheap fingerprint of the analyzed clip dirs (each analysis.json's name,
873
+ mtime, and size) so the persisted index can be reused until a report is
874
+ added, removed, or rewritten — without reparsing every report each poll."""
875
+ parts: List[str] = []
876
+ try:
877
+ entries = sorted(os.listdir(clips_root))
878
+ except OSError:
879
+ return "0:none"
880
+ for entry in entries:
881
+ report = os.path.join(clips_root, entry, "analysis.json")
882
+ try:
883
+ stat = os.stat(report)
884
+ except OSError:
885
+ continue
886
+ parts.append(f"{entry}:{stat.st_mtime_ns}:{stat.st_size}")
887
+ return f"{len(parts)}:{short_hash('|'.join(parts), 16)}"
888
+
889
+
890
+ def build_clip_index(project_root: str) -> Dict[str, Any]:
891
+ """Build and persist a hash -> folder index for the project's reports.
892
+
893
+ Unlike a folder-name scan (which only knows the single hash baked into each
894
+ directory name), this reads each report's ``clip`` block and indexes ALL of
895
+ its stable ids (normalized + raw file path, clip_id, media_id). That lets the
896
+ analyzed-count match a clip by any id it still carries — e.g. an offline clip
897
+ that no longer reports a file path but still has its clip_id. See #51.
898
+ """
899
+ clips_root = os.path.join(project_root, "clips")
900
+ hash_to_folder: Dict[str, str] = {}
901
+ if os.path.isdir(clips_root):
902
+ try:
903
+ entries = sorted(os.listdir(clips_root))
904
+ except OSError:
905
+ entries = []
906
+ for entry in entries:
907
+ report_path = os.path.join(clips_root, entry, "analysis.json")
908
+ if not os.path.isfile(report_path):
909
+ continue
910
+ try:
911
+ report = _read_json(report_path)
912
+ except (OSError, json.JSONDecodeError):
913
+ continue
914
+ clip_block = report.get("clip") if isinstance(report.get("clip"), dict) else {}
915
+ hashes = set(stable_clip_match_hashes(clip_block))
916
+ folder_hash = clip_directory_hash(entry) # the hash baked into the name
917
+ if folder_hash:
918
+ hashes.add(folder_hash)
919
+ for digest in hashes:
920
+ hash_to_folder.setdefault(digest, entry)
921
+ payload = {
922
+ "schema_version": CLIP_INDEX_SCHEMA_VERSION,
923
+ "signature": _clip_dir_signature(clips_root),
924
+ "hash_to_folder": hash_to_folder,
925
+ }
926
+ if os.path.isdir(clips_root):
927
+ try:
928
+ _write_json(clip_index_path(project_root), payload)
929
+ except OSError:
930
+ pass
931
+ return payload
932
+
933
+
934
+ def load_clip_index(project_root: str, *, rebuild_if_stale: bool = True) -> Dict[str, Any]:
935
+ """Load the persisted clip index, rebuilding it if missing or stale.
936
+
937
+ Freshness is decided by the cheap directory signature, so the common poll
938
+ pays a stat-per-report instead of a full JSON reparse; a rebuild only happens
939
+ when a report is added, removed, or rewritten.
940
+ """
941
+ clips_root = os.path.join(project_root, "clips")
942
+ current_sig = _clip_dir_signature(clips_root)
943
+ try:
944
+ data = _read_json(clip_index_path(project_root))
945
+ except (OSError, json.JSONDecodeError):
946
+ data = None
947
+ if (
948
+ isinstance(data, dict)
949
+ and data.get("schema_version") == CLIP_INDEX_SCHEMA_VERSION
950
+ and data.get("signature") == current_sig
951
+ and isinstance(data.get("hash_to_folder"), dict)
952
+ ):
953
+ return data
954
+ if rebuild_if_stale:
955
+ return build_clip_index(project_root)
956
+ return {
957
+ "schema_version": CLIP_INDEX_SCHEMA_VERSION,
958
+ "signature": current_sig,
959
+ "hash_to_folder": {},
960
+ }
752
961
 
753
962
 
754
963
  def normalize_path(path: Any) -> str:
@@ -1681,7 +1890,7 @@ def _bounded_frame_count(depth: str, requested: Any = None) -> int:
1681
1890
 
1682
1891
 
1683
1892
  def _artifact_paths(project_root: str, record: Dict[str, Any], depth: str, options: Dict[str, Any]) -> Dict[str, Any]:
1684
- clip_dir = normalize_path(os.path.join(project_root, "clips", stable_clip_directory(record)))
1893
+ clip_dir = resolve_clip_directory(project_root, record)
1685
1894
  artifacts: Dict[str, Any] = {
1686
1895
  "clip_dir": clip_dir,
1687
1896
  "analysis_json": os.path.join(clip_dir, "analysis.json"),