davinci-resolve-mcp 4.8.1 → 4.8.3

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,137 @@
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 v4.8.3 — nested folder ids resolve for delete and move
6
+
7
+ ### Fixed
8
+
9
+ - **`media_pool.delete_folders` and `media_pool.move_folders` resolve
10
+ `folder_ids` anywhere in the Media Pool tree.** Both actions scanned only the
11
+ root folder's direct children, so an id belonging to any nested folder came
12
+ back `{"error":{"message":"No folders found"}}` — even though the caller was
13
+ holding the id `GetUniqueId()` had just handed them, and `mp.DeleteFolders()`
14
+ on the resolved object worked fine. Both now use the existing recursive
15
+ `_find_folder_by_id` walk through a shared `_folders_from_ids` resolver.
16
+ - **An unresolved id fails the call instead of being dropped** — the same
17
+ all-or-nothing rule 4.8.2 applied to the clip actions. Previously only an
18
+ all-empty result errored, so a mixed batch deleted or moved the subset that
19
+ happened to resolve and answered `{"success": true}`; `move_folders` did not
20
+ even stop at an empty result and answered `success: true` after
21
+ `MoveFolders([], target)`. Unresolved ids now return `FOLDER_NOT_FOUND` /
22
+ `invalid_input` naming both the unresolved and the resolved ids in `state`,
23
+ with nothing deleted or moved.
24
+ - An empty or non-list `folder_ids` returns `INVALID_FOLDER_IDS` instead of
25
+ iterating a bare string character by character; a missing one keeps the
26
+ surface-wide `MISSING_FOLDER_IDS`. The root (Master) folder — newly reachable
27
+ now that the search is recursive — is refused with
28
+ `ROOT_FOLDER_NOT_ELIGIBLE` before Resolve sees it. `move_folders`' "Target
29
+ folder not found" now carries `FOLDER_NOT_FOUND` / `invalid_input` too.
30
+
31
+ ### Validation
32
+
33
+ - New `tests/test_media_pool_folder_ids.py` pins both actions against a
34
+ three-level-deep folder, the partial-batch case, and the boundary inputs.
35
+ Against the 4.8.2 code 10 of its 11 tests fail; all 11 pass after.
36
+ - Offline suite: 3,732 tests run, 84 skipped, 11 errors — the same environment
37
+ gaps as in 4.8.2 (`numpy` and `requests` absent from the venv, `node_modules`
38
+ not installed). None touch `media_pool`. Drift guards, api-parity,
39
+ api-limitations and read/write symmetry clean; tool counts unchanged.
40
+ - Live reproduction on Resolve Studio 21.1.0.14 (reporter): deleting
41
+ `Master/OUTDOORS/1_FOOTAGE/wetransfer_dscf1065-mov_2022-01-31_1207` by its
42
+ `GetUniqueId()` returned `No folders found`, while `mp.DeleteFolders()` with
43
+ the recursively resolved folder object succeeded.
44
+ - **Live-validated on Resolve Studio 21.1.0.14** with the new
45
+ `tests/live_nested_folder_ids_check.py`
46
+ (`venv/bin/python tests/live_nested_folder_ids_check.py`) against a real project
47
+ of 144 folders and 2,600 clips, probing a folder seven levels below Master.
48
+ `delete_folders` on its id reached `confirmation_required` naming exactly that
49
+ folder; `move_folders` resolved it and its current parent as the target;
50
+ partial and all-missing batches came back `FOLDER_NOT_FOUND` for both actions,
51
+ Master `ROOT_FOLDER_NOT_ELIGIBLE`, bare string and empty list
52
+ `INVALID_FOLDER_IDS`, no key `MISSING_FOLDER_IDS`. The check is strictly
53
+ non-destructive, with the same tripwire as the 4.8.2 clip check: the MediaPool
54
+ it hands the actions forwards only `GetRootFolder` / `GetCurrentFolder` and
55
+ intercepts every other method, so `DeleteFolders` / `MoveFolders` never reach
56
+ Resolve whatever the code does; token gating is forced on and no token is
57
+ passed; move probes aim at the folder's current parent; the destructive hook's
58
+ analysis-root writes are disabled and its logs go to a temp directory. Every
59
+ folder's parent and every clip's folder read back identical after the run.
60
+ - The same live check against the 4.8.2 code fails with 14 findings and
61
+ reproduces the reported bug: the depth-7 id came back `No folders found` from
62
+ `delete_folders`, and the tripwire intercepted five `MoveFolders` calls with an
63
+ empty folder list, each answered as a plain result rather than an error. The
64
+ pool was unchanged after that run too.
65
+
66
+ ## What's New in v4.8.2 — a clip id that resolves to nothing fails the whole clip batch
67
+
68
+ ### Fixed
69
+
70
+ - **`media_pool` `delete_clips`, `move_clips`, `relink` and `unlink` no longer act on
71
+ the part of a batch that happened to resolve.** All four resolved `clip_ids` with
72
+ `[_find_clip(root, cid) for cid in ...]` and then dropped every miss, so Resolve was
73
+ handed whatever was left. `delete_clips` errored only when *every* id missed
74
+ (`"No clips found"`); a mixed batch deleted the subset that resolved and answered
75
+ `{"success": true}`, and with confirm tokens on, the preview and the token covered
76
+ that subset too. `move_clips`, `relink` and `unlink` did not check for an empty
77
+ result at all: an all-missing batch reached `MoveClips([], target)` /
78
+ `RelinkClips([], path)` / `UnlinkClips([])` and reported whatever Resolve's bool
79
+ said. The caller could not tell a partial change from a whole one.
80
+ - A new `_clips_from_ids` resolver fails the call before anything reaches Resolve
81
+ (and before a confirm token is issued) with `CLIP_NOT_FOUND` /
82
+ `invalid_input`, naming the ids in `error.state.unresolved_clip_ids` and
83
+ `error.state.resolved_clip_ids`. Nothing is deleted, moved, relinked or unlinked.
84
+ - An empty or non-list `clip_ids` returns `INVALID_CLIP_IDS` instead of iterating a
85
+ bare string character by character; a missing one keeps the surface-wide
86
+ `MISSING_CLIP_IDS`.
87
+
88
+ ### Not changed
89
+
90
+ - The same drop-the-misses pattern remains in four other `media_pool` actions, which
91
+ this release does not touch: `create_timeline_from_clips` (simple `clip_ids` mode)
92
+ builds the timeline from the subset, `append_to_timeline` (legacy `clip_ids` mode)
93
+ appends the subset and verifies against the resolved count, `export_metadata`
94
+ exports the subset — and with every id missing calls `ExportMetadata(path, [])`,
95
+ whose behaviour on an empty list is not measured — and `auto_sync_audio` syncs the
96
+ subset. The granular server's `delete_media_pool_clips` and `move_clips_to_folder`
97
+ have the same partial-batch behaviour (they do report a count). The `safe_*` /
98
+ `organize_clips` family reports its misses in `missing` rather than dropping them
99
+ silently.
100
+
101
+ ### Validation
102
+
103
+ - New `tests/test_media_pool_clip_ids.py` pins all four actions against a fake
104
+ Media Pool that records every mutation call: a partial batch, an all-missing
105
+ batch, a partial `delete_clips` under confirm-token gating (no token is issued),
106
+ and the missing / empty / bare-string `clip_ids` shapes, plus the happy path with
107
+ a clip two folders deep. Against the pre-fix code 5 of its 10 tests fail
108
+ (17 subtests — every partial and all-missing case, the token case and both
109
+ shape cases); the 5 that pin preserved behaviour pass on both. All 10 pass after.
110
+ - Offline suite: 3,721 tests run, 84 skipped, 11 errors — all environment gaps on the
111
+ machine that ran it (`numpy` and `requests` absent from the venv, so five test
112
+ modules fail to import and one LUT test cannot do arithmetic; `node_modules` not
113
+ installed, so five `test_offline_fallback` cases cannot load `jszip`). None touch
114
+ `media_pool`. Drift guards, api-parity, api-limitations and read/write symmetry
115
+ clean; `test_static_undefined_names` skipped because pyflakes is not installed.
116
+ - **Live-validated on Resolve Studio 21.1.0.14** with the new
117
+ `tests/live_clip_ids_check.py` (`venv/bin/python tests/live_clip_ids_check.py`)
118
+ against a real project of 144 folders and 2,600 clips, probing a clip seven
119
+ folders deep. Partial and all-missing batches came back `CLIP_NOT_FOUND` for all
120
+ four actions, with no confirm token for the partial delete; bare string and
121
+ empty list → `INVALID_CLIP_IDS`, no key → `MISSING_CLIP_IDS`; a whole batch still
122
+ resolved to the real clip. The check is strictly non-destructive: the MediaPool it
123
+ hands the actions forwards only `GetRootFolder` / `GetCurrentFolder` and
124
+ intercepts every other method, so `DeleteClips` / `MoveClips` / `RelinkClips` /
125
+ `UnlinkClips` never reach Resolve whatever the code does; token gating is forced
126
+ on and no token is passed; the destructive hook's analysis-root writes are
127
+ disabled and its logs go to a temp directory. Every clip's folder, every folder's
128
+ parent and the probe clip's File Path read back identical after the run.
129
+ - The same live check against the pre-fix code fails with 18 findings: the tripwire
130
+ intercepted 10 MediaPool mutations the old code sent — `MoveClips`,
131
+ `RelinkClips` and `UnlinkClips` with just the probe clip for the partial batches,
132
+ the same three with an empty list for the all-missing batches, `UnlinkClips([])`
133
+ for `clip_ids: []` — and the partial `delete_clips` was issued a confirm token.
134
+ The pool was unchanged after that run too.
135
+
5
136
  ## What's New in v4.8.1 — text modifiers (Follower) attach to TextPlus inputs
6
137
 
7
138
  ### Added
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.8.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(389%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.8.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(389%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v4.8.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.8.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/SKILL.md CHANGED
@@ -922,6 +922,14 @@ Key actions: `get_root_folder`, `get_current_folder`, `set_current_folder(path)`
922
922
  `setup_multicam_timeline(name, clip_ids|angles, sync_mode?, include_audio?, dry_run?)`,
923
923
  `get_selected`, `set_selected(clip_id)`, `export_metadata(path, clip_ids?)`
924
924
 
925
+ `delete_clips`, `move_clips`, `relink` and `unlink` are all-or-nothing: if any id
926
+ in `clip_ids` matches no clip, the call fails with `CLIP_NOT_FOUND` (the
927
+ unresolved and resolved ids are in `error.state`) and no clip is changed. Drop the
928
+ stale ids and retry; do not read a partial batch as done. `delete_folders(folder_ids)`
929
+ and `move_folders(folder_ids, target_path)` work the same way with
930
+ `FOLDER_NOT_FOUND`; they resolve `folder_ids` at any depth (pass the ids
931
+ `folder get_subfolders` returns) and refuse the Master folder itself.
932
+
925
933
  Media Pool / Ingest kernel actions (v2.8.0+) add safer agent-facing workflows:
926
934
  `ingest_capabilities`, `probe_media_pool`, `probe_ingest_item`,
927
935
  `safe_import_media`, `safe_import_sequence`, `safe_import_folder`,
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.8.1"
40
+ VERSION = "4.8.3"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (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": "4.8.1",
3
+ "version": "4.8.3",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.8.1"
96
+ VERSION = "4.8.3"
97
97
  logger = logging.getLogger("davinci-resolve-mcp")
98
98
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
99
99
  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 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.8.1"
14
+ VERSION = "4.8.3"
15
15
 
16
16
  import base64
17
17
  import os
@@ -2912,6 +2912,45 @@ def _find_clip(folder, clip_id):
2912
2912
  return None
2913
2913
 
2914
2914
 
2915
+ def _clips_from_ids(root, clip_ids, *, verb):
2916
+ """Resolve Media Pool clip unique IDs to clip objects. Returns (clips, error).
2917
+
2918
+ Unresolved ids abort the call instead of being dropped. The raw clip actions
2919
+ used to filter misses out and hand Resolve whatever was left, so a mixed batch
2920
+ deleted, moved, relinked or unlinked the subset that happened to resolve and
2921
+ answered plain {"success": true} — indistinguishable from every id having
2922
+ resolved. move_clips/relink/unlink did not even stop at an empty result:
2923
+ MoveClips([], target) moved nothing and still reported success.
2924
+
2925
+ A non-list is refused rather than iterated: a bare string used to become one
2926
+ id per character, each matching nothing and each silently dropped.
2927
+ """
2928
+ if not isinstance(clip_ids, list) or not clip_ids:
2929
+ return None, _err(
2930
+ "clip_ids must be a non-empty list of Media Pool clip unique IDs",
2931
+ code="INVALID_CLIP_IDS", category="invalid_input",
2932
+ remediation="Pass clip_ids as a list, e.g. params={\"clip_ids\": [\"<id>\"]}.")
2933
+ clips, resolved, unresolved = [], [], []
2934
+ for cid in clip_ids:
2935
+ cid = str(cid)
2936
+ found = _find_clip(root, cid)
2937
+ if found is None:
2938
+ unresolved.append(cid)
2939
+ else:
2940
+ clips.append(found)
2941
+ resolved.append(cid)
2942
+ if unresolved:
2943
+ return None, _err(
2944
+ f"Clip(s) not found: {', '.join(unresolved)}",
2945
+ code="CLIP_NOT_FOUND", category="invalid_input",
2946
+ reason=(f"{len(unresolved)} of {len(clip_ids)} clip_ids matched no clip "
2947
+ f"anywhere in the Media Pool; nothing was {verb}."),
2948
+ remediation=("List clips with folder get_clips (or media_pool get_selected) "
2949
+ "and pass only ids that still exist."),
2950
+ state={"unresolved_clip_ids": unresolved, "resolved_clip_ids": resolved})
2951
+ return clips, None
2952
+
2953
+
2915
2954
  def _find_clip_with_parent(folder, clip_id, _parent=None):
2916
2955
  """Return (clip, parent_folder) for clip_id, searching recursively.
2917
2956
 
@@ -2928,6 +2967,13 @@ def _find_clip_with_parent(folder, clip_id, _parent=None):
2928
2967
  return found_clip, found_parent
2929
2968
  return None, None
2930
2969
 
2970
+
2971
+ _FOLDER_ID_REMEDIATION = (
2972
+ "List folders with folder get_subfolders (walking down from "
2973
+ "path=\"Master\") and address by exact path or folder_id."
2974
+ )
2975
+
2976
+
2931
2977
  def _find_folder_by_id(folder, folder_id):
2932
2978
  if folder.GetUniqueId() == folder_id:
2933
2979
  return folder
@@ -2938,6 +2984,57 @@ def _find_folder_by_id(folder, folder_id):
2938
2984
  return None
2939
2985
 
2940
2986
 
2987
+ def _folders_from_ids(root, folder_ids, *, verb):
2988
+ """Resolve Media Pool folder unique IDs to folder objects. Returns (folders, error).
2989
+
2990
+ Searches the WHOLE tree. The ids callers hold come from GetUniqueId() /
2991
+ folder.get_subfolders, which hand them out at every depth, so the shallow
2992
+ scan of root's direct children this replaced answered "No folders found" for
2993
+ any nested folder — verified live on Resolve Studio 21.1.0.14, where
2994
+ deleting Master/OUTDOORS/1_FOOTAGE/<clip bin> by its own id failed while
2995
+ mp.DeleteFolders() on the recursively resolved object worked.
2996
+
2997
+ Unresolved ids abort the call instead of being dropped. Skipping them let a
2998
+ partial match delete or move the subset it did find and answer plain
2999
+ {"success": true}, which is indistinguishable from every id having resolved.
3000
+
3001
+ The root (Master) folder is refused rather than passed to Resolve: it cannot
3002
+ be deleted or moved, and the shallow search could never return it.
3003
+ """
3004
+ if not isinstance(folder_ids, list) or not folder_ids:
3005
+ return None, _err(
3006
+ "folder_ids must be a non-empty list of folder unique IDs",
3007
+ code="INVALID_FOLDER_IDS", category="invalid_input",
3008
+ remediation=_FOLDER_ID_REMEDIATION)
3009
+ root_id = root.GetUniqueId()
3010
+ folders, unresolved, named_root = [], [], False
3011
+ for fid in folder_ids:
3012
+ fid = str(fid)
3013
+ if fid == root_id:
3014
+ named_root = True
3015
+ continue
3016
+ found = _find_folder_by_id(root, fid)
3017
+ if found is None:
3018
+ unresolved.append(fid)
3019
+ else:
3020
+ folders.append(found)
3021
+ if named_root:
3022
+ return None, _err(
3023
+ f"The root (Master) folder cannot be {verb}",
3024
+ code="ROOT_FOLDER_NOT_ELIGIBLE", category="invalid_input",
3025
+ state={"root_folder_id": root_id})
3026
+ if unresolved:
3027
+ return None, _err(
3028
+ f"Folder(s) not found: {', '.join(unresolved)}",
3029
+ code="FOLDER_NOT_FOUND", category="invalid_input",
3030
+ reason=(f"{len(unresolved)} of {len(folder_ids)} folder_ids matched no folder "
3031
+ f"anywhere in the Media Pool; nothing was {verb}."),
3032
+ remediation=_FOLDER_ID_REMEDIATION,
3033
+ state={"unresolved_folder_ids": unresolved,
3034
+ "resolved_folder_ids": [f.GetUniqueId() for f in folders]})
3035
+ return folders, None
3036
+
3037
+
2941
3038
  def _folder_from_params(mp, p, *path_keys, no_address="current"):
2942
3039
  """Resolve the folder an action was aimed at. Returns (folder, error).
2943
3040
 
@@ -2958,8 +3055,7 @@ def _folder_from_params(mp, p, *path_keys, no_address="current"):
2958
3055
  `_navigate_folder(mp, "")`, which returns root). This fix must not also
2959
3056
  change what omitting the address means.
2960
3057
  """
2961
- remediation = ("List folders with folder get_subfolders (walking down from "
2962
- "path=\"Master\") and address by exact path or folder_id.")
3058
+ remediation = _FOLDER_ID_REMEDIATION
2963
3059
  path = _first_param(p, *path_keys)
2964
3060
  if path:
2965
3061
  f = _navigate_folder(mp, path)
@@ -21049,7 +21145,10 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
21049
21145
  add_subfolder(name, parent_path?) -> {success, name, id}
21050
21146
  delete_folders(folder_ids) -> {success}
21051
21147
  DESTRUCTIVE. Deletes folders + every clip they contain.
21148
+ folder_ids resolve at ANY depth; an id matching nothing fails the whole
21149
+ call (FOLDER_NOT_FOUND) rather than deleting the ids that did resolve.
21052
21150
  move_folders(folder_ids, target_path) -> {success}
21151
+ folder_ids resolve at ANY depth; unresolved ids fail the whole call.
21053
21152
  refresh() -> {success}
21054
21153
  create_timeline(name, if_exists?) -> {success, name, id}
21055
21154
  — if_exists: version (default), reuse, or fail
@@ -21086,6 +21185,9 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
21086
21185
  Example: [{"FilePath": "frame_%03d.dpx", "StartIndex": 1, "EndIndex": 100}]
21087
21186
  delete_clips(clip_ids) -> {success}
21088
21187
  DESTRUCTIVE. Removes clips from the Media Pool (does not touch source files).
21188
+ An id matching no clip fails the whole call (CLIP_NOT_FOUND) rather than
21189
+ deleting the ids that did resolve; the same holds for move_clips, relink
21190
+ and unlink.
21089
21191
  move_clips(clip_ids, target_path) -> {success}
21090
21192
  relink(clip_ids, folder_path) -> {success}
21091
21193
  UNSAFE. No dry_run. Prefer safe_relink.
@@ -21154,14 +21256,9 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
21154
21256
  f = mp.AddSubFolder(parent, p["name"])
21155
21257
  return _ok(name=f.GetName(), id=f.GetUniqueId()) if f else _err("Failed to create subfolder")
21156
21258
  elif action == "delete_folders":
21157
- folders = []
21158
- for fid in p["folder_ids"]:
21159
- # Search for folder by ID (simplified - searches root subfolders)
21160
- for sub in (root.GetSubFolderList() or []):
21161
- if sub.GetUniqueId() == fid:
21162
- folders.append(sub)
21163
- if not folders:
21164
- return _err("No folders found")
21259
+ folders, folders_err = _folders_from_ids(root, p["folder_ids"], verb="deleted")
21260
+ if folders_err:
21261
+ return folders_err
21165
21262
  if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
21166
21263
  return _issue_confirm_token(
21167
21264
  action="media_pool.delete_folders", params=p,
@@ -21177,12 +21274,12 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
21177
21274
  elif action == "move_folders":
21178
21275
  target = _navigate_folder(mp, p["target_path"])
21179
21276
  if not target:
21180
- return _err(f"Target folder not found: {p['target_path']}")
21181
- folders = []
21182
- for fid in p["folder_ids"]:
21183
- for sub in (root.GetSubFolderList() or []):
21184
- if sub.GetUniqueId() == fid:
21185
- folders.append(sub)
21277
+ return _err(f"Target folder not found: {p['target_path']}",
21278
+ code="FOLDER_NOT_FOUND", category="invalid_input",
21279
+ remediation=_FOLDER_ID_REMEDIATION)
21280
+ folders, folders_err = _folders_from_ids(root, p["folder_ids"], verb="moved")
21281
+ if folders_err:
21282
+ return folders_err
21186
21283
  return {"success": bool(mp.MoveFolders(folders, target))}
21187
21284
  elif action == "refresh":
21188
21285
  return {"success": bool(mp.RefreshFolders())}
@@ -21393,10 +21490,9 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
21393
21490
  result = mp.ImportMedia(paths)
21394
21491
  return {"imported": len(result) if result else 0}
21395
21492
  elif action == "delete_clips":
21396
- clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
21397
- clips = [c for c in clips if c]
21398
- if not clips:
21399
- return _err("No clips found")
21493
+ clips, clips_err = _clips_from_ids(root, p["clip_ids"], verb="deleted")
21494
+ if clips_err:
21495
+ return clips_err
21400
21496
  if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
21401
21497
  return _issue_confirm_token(
21402
21498
  action="media_pool.delete_clips", params=p,
@@ -21413,16 +21509,19 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
21413
21509
  target = _navigate_folder(mp, p["target_path"])
21414
21510
  if not target:
21415
21511
  return _err(f"Target folder not found: {p['target_path']}")
21416
- clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
21417
- clips = [c for c in clips if c]
21512
+ clips, clips_err = _clips_from_ids(root, p["clip_ids"], verb="moved")
21513
+ if clips_err:
21514
+ return clips_err
21418
21515
  return {"success": bool(mp.MoveClips(clips, target))}
21419
21516
  elif action == "relink":
21420
- clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
21421
- clips = [c for c in clips if c]
21517
+ clips, clips_err = _clips_from_ids(root, p["clip_ids"], verb="relinked")
21518
+ if clips_err:
21519
+ return clips_err
21422
21520
  return {"success": bool(mp.RelinkClips(clips, p["folder_path"]))}
21423
21521
  elif action == "unlink":
21424
- clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
21425
- clips = [c for c in clips if c]
21522
+ clips, clips_err = _clips_from_ids(root, p["clip_ids"], verb="unlinked")
21523
+ if clips_err:
21524
+ return clips_err
21426
21525
  return {"success": bool(mp.UnlinkClips(clips))}
21427
21526
  elif action == "export_metadata":
21428
21527
  clip_ids = p.get("clip_ids")