vflow-cli 0.1.1__py3-none-any.whl
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.
- vflow/__init__.py +1 -0
- vflow/actions.py +39 -0
- vflow/backup_service.py +684 -0
- vflow/config.py +44 -0
- vflow/core/__init__.py +6 -0
- vflow/core/date_utils.py +114 -0
- vflow/core/fs_ops.py +64 -0
- vflow/core/media_ops.py +110 -0
- vflow/core/patterns.py +98 -0
- vflow/delivery_service.py +301 -0
- vflow/ingest_service.py +932 -0
- vflow/main.py +571 -0
- vflow_cli-0.1.1.dist-info/METADATA +543 -0
- vflow_cli-0.1.1.dist-info/RECORD +17 -0
- vflow_cli-0.1.1.dist-info/WHEEL +5 -0
- vflow_cli-0.1.1.dist-info/entry_points.txt +2 -0
- vflow_cli-0.1.1.dist-info/top_level.txt +1 -0
vflow/ingest_service.py
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .core.date_utils import (
|
|
11
|
+
parse_shoot_date_range,
|
|
12
|
+
format_shoot_name,
|
|
13
|
+
cluster_files_by_date,
|
|
14
|
+
)
|
|
15
|
+
from .core.fs_ops import copy_and_verify, _build_destination_index, _is_duplicate
|
|
16
|
+
from .core.patterns import _extract_number_from_filename, _matches_pattern
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_media_date(file_path: Path) -> datetime:
|
|
20
|
+
"""
|
|
21
|
+
Extract the date/time from a media file.
|
|
22
|
+
Uses filesystem creation date (birthtime) if available, else modification time.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
stat = file_path.stat()
|
|
26
|
+
creation_time = getattr(stat, "st_birthtime", None)
|
|
27
|
+
if creation_time:
|
|
28
|
+
return datetime.fromtimestamp(creation_time)
|
|
29
|
+
return datetime.fromtimestamp(stat.st_mtime)
|
|
30
|
+
except Exception:
|
|
31
|
+
return datetime.now()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _find_existing_shoots(laptop_dest: Path, archive_dest: Path) -> dict:
|
|
35
|
+
"""
|
|
36
|
+
Find all existing shoots and their date ranges, tracking where they exist.
|
|
37
|
+
Returns a dict mapping shoot_name -> {
|
|
38
|
+
'date_range': (start_date, end_date),
|
|
39
|
+
'in_laptop': bool,
|
|
40
|
+
'in_archive': bool
|
|
41
|
+
}
|
|
42
|
+
"""
|
|
43
|
+
shoots: dict[str, dict] = {}
|
|
44
|
+
|
|
45
|
+
if laptop_dest.exists():
|
|
46
|
+
for shoot_dir in laptop_dest.iterdir():
|
|
47
|
+
if shoot_dir.is_dir():
|
|
48
|
+
date_range = parse_shoot_date_range(shoot_dir.name)
|
|
49
|
+
if date_range:
|
|
50
|
+
shoots[shoot_dir.name] = {
|
|
51
|
+
"date_range": date_range,
|
|
52
|
+
"in_laptop": True,
|
|
53
|
+
"in_archive": False,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
archive_raw = archive_dest / "Video" / "RAW"
|
|
57
|
+
if archive_raw.exists():
|
|
58
|
+
for shoot_dir in archive_raw.iterdir():
|
|
59
|
+
if shoot_dir.is_dir():
|
|
60
|
+
date_range = parse_shoot_date_range(shoot_dir.name)
|
|
61
|
+
if date_range:
|
|
62
|
+
if shoot_dir.name in shoots:
|
|
63
|
+
shoots[shoot_dir.name]["in_archive"] = True
|
|
64
|
+
else:
|
|
65
|
+
shoots[shoot_dir.name] = {
|
|
66
|
+
"date_range": date_range,
|
|
67
|
+
"in_laptop": False,
|
|
68
|
+
"in_archive": True,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return shoots
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _find_matching_shoot(file_date_range: tuple, existing_shoots: dict) -> Optional[str]:
|
|
75
|
+
"""
|
|
76
|
+
Find an existing shoot whose date range contains the file date range.
|
|
77
|
+
Returns shoot name or None.
|
|
78
|
+
"""
|
|
79
|
+
file_start, file_end = file_date_range
|
|
80
|
+
for shoot_name, shoot_info in existing_shoots.items():
|
|
81
|
+
shoot_start, shoot_end = shoot_info["date_range"]
|
|
82
|
+
if shoot_start <= file_start and file_end <= shoot_end:
|
|
83
|
+
return shoot_name
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ingest_report(
|
|
88
|
+
source_dir: str,
|
|
89
|
+
archive_path: Path,
|
|
90
|
+
laptop_path: Optional[Path] = None,
|
|
91
|
+
priority_day: Optional[int] = None,
|
|
92
|
+
priority_month: Optional[int] = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Scans SD card (or source) for video files, compares against BOTH laptop ingest
|
|
96
|
+
and archive, and reports what has not been ingested. A file is considered
|
|
97
|
+
ingested if it exists (same name and size) in either destination.
|
|
98
|
+
Optionally highlights a priority date (e.g. 28th).
|
|
99
|
+
"""
|
|
100
|
+
source_path = Path(source_dir)
|
|
101
|
+
if not source_path.exists() or not source_path.is_dir():
|
|
102
|
+
typer.echo(f"Source directory not found: {source_path}", err=True)
|
|
103
|
+
raise typer.Exit(code=1)
|
|
104
|
+
|
|
105
|
+
video_extensions = {
|
|
106
|
+
".mp4",
|
|
107
|
+
".mov",
|
|
108
|
+
".mxf",
|
|
109
|
+
".mts",
|
|
110
|
+
".avi",
|
|
111
|
+
".m4v",
|
|
112
|
+
".braw",
|
|
113
|
+
".r3d",
|
|
114
|
+
".crm",
|
|
115
|
+
}
|
|
116
|
+
all_files: list[Path] = []
|
|
117
|
+
for file_path in source_path.rglob("*"):
|
|
118
|
+
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
|
|
119
|
+
all_files.append(file_path)
|
|
120
|
+
|
|
121
|
+
if not all_files:
|
|
122
|
+
typer.echo("No video files found in the source directory.", err=True)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
laptop_index: set[tuple[str, int]] = set()
|
|
126
|
+
if laptop_path and laptop_path.exists():
|
|
127
|
+
for f in laptop_path.rglob("*"):
|
|
128
|
+
if f.is_file() and f.suffix.lower() in video_extensions:
|
|
129
|
+
try:
|
|
130
|
+
laptop_index.add((f.name, f.stat().st_size))
|
|
131
|
+
except (OSError, FileNotFoundError):
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
archive_raw = archive_path / "Video" / "RAW"
|
|
135
|
+
archive_index: set[tuple[str, int]] = set()
|
|
136
|
+
if archive_raw.exists():
|
|
137
|
+
for f in archive_raw.rglob("*"):
|
|
138
|
+
if f.is_file() and f.suffix.lower() in video_extensions:
|
|
139
|
+
try:
|
|
140
|
+
archive_index.add((f.name, f.stat().st_size))
|
|
141
|
+
except (OSError, FileNotFoundError):
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
from collections import defaultdict
|
|
145
|
+
|
|
146
|
+
by_date: dict[date, list[tuple[Path, int, bool, bool]]] = defaultdict(list)
|
|
147
|
+
for f in all_files:
|
|
148
|
+
try:
|
|
149
|
+
dt = _get_media_date(f)
|
|
150
|
+
d = dt.date()
|
|
151
|
+
size = f.stat().st_size
|
|
152
|
+
key = (f.name, size)
|
|
153
|
+
in_laptop = key in laptop_index
|
|
154
|
+
in_archive = key in archive_index
|
|
155
|
+
by_date[d].append((f, size, in_laptop, in_archive))
|
|
156
|
+
except (OSError, FileNotFoundError):
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
dates_sorted = sorted(by_date.keys())
|
|
160
|
+
|
|
161
|
+
typer.echo("\n" + "=" * 70)
|
|
162
|
+
typer.echo("INGEST REPORT (SD CARD = SOURCE OF TRUTH)")
|
|
163
|
+
typer.echo("=" * 70)
|
|
164
|
+
typer.echo(f"SD card: {source_path}")
|
|
165
|
+
typer.echo(f"Laptop: {laptop_path}")
|
|
166
|
+
typer.echo(f"Archive: {archive_raw}")
|
|
167
|
+
typer.echo(
|
|
168
|
+
f"Total on SD: {len(all_files)} | Laptop index: {len(laptop_index)} | Archive index: {len(archive_index)}"
|
|
169
|
+
)
|
|
170
|
+
typer.echo("=" * 70)
|
|
171
|
+
|
|
172
|
+
not_on_laptop_by_date: dict[date, list] = {}
|
|
173
|
+
not_on_archive_by_date: dict[date, list] = {}
|
|
174
|
+
for d in dates_sorted:
|
|
175
|
+
items = by_date[d]
|
|
176
|
+
on_laptop = sum(1 for _, _, lb, _ in items if lb)
|
|
177
|
+
on_archive = sum(1 for _, _, _, ab in items if ab)
|
|
178
|
+
missing_laptop = [x for x in items if not x[2]]
|
|
179
|
+
missing_archive = [x for x in items if not x[3]]
|
|
180
|
+
not_on_laptop_by_date[d] = missing_laptop
|
|
181
|
+
not_on_archive_by_date[d] = missing_archive
|
|
182
|
+
priority_mark = ""
|
|
183
|
+
if priority_day is not None and d.day == priority_day:
|
|
184
|
+
if priority_month is None or d.month == priority_month:
|
|
185
|
+
priority_mark = " << PRIORITY"
|
|
186
|
+
typer.echo(
|
|
187
|
+
f"\n{d} on SD: {len(items)} | laptop: {on_laptop}/{len(items)} | archive: {on_archive}/{len(items)}{priority_mark}"
|
|
188
|
+
)
|
|
189
|
+
if missing_laptop:
|
|
190
|
+
names = sorted(x[0].name for x in missing_laptop)
|
|
191
|
+
typer.echo(
|
|
192
|
+
" Missing from laptop: "
|
|
193
|
+
+ (
|
|
194
|
+
", ".join(names)
|
|
195
|
+
if len(names) <= 10
|
|
196
|
+
else f"{names[0]} .. {names[-1]} ({len(names)} files)"
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
if missing_archive:
|
|
200
|
+
names = sorted(x[0].name for x in missing_archive)
|
|
201
|
+
typer.echo(
|
|
202
|
+
" Missing from archive: "
|
|
203
|
+
+ (
|
|
204
|
+
", ".join(names)
|
|
205
|
+
if len(names) <= 10
|
|
206
|
+
else f"{names[0]} .. {names[-1]} ({len(names)} files)"
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
on_both = sum(
|
|
211
|
+
1 for d in dates_sorted for (_, _, lb, ab) in by_date[d] if lb and ab
|
|
212
|
+
)
|
|
213
|
+
laptop_only_from_sd = sum(
|
|
214
|
+
1 for d in dates_sorted for (_, _, lb, ab) in by_date[d] if lb and not ab
|
|
215
|
+
)
|
|
216
|
+
archive_only_from_sd = sum(
|
|
217
|
+
1 for d in dates_sorted for (_, _, lb, ab) in by_date[d] if ab and not lb
|
|
218
|
+
)
|
|
219
|
+
on_neither = sum(
|
|
220
|
+
1 for d in dates_sorted for (_, _, lb, ab) in by_date[d] if not lb and not ab
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
typer.echo("\n" + "=" * 70)
|
|
224
|
+
typer.echo("SUMMARY (files on SD card)")
|
|
225
|
+
typer.echo("=" * 70)
|
|
226
|
+
typer.echo(f" On both laptop + archive: {on_both}")
|
|
227
|
+
typer.echo(f" On laptop only: {laptop_only_from_sd}")
|
|
228
|
+
typer.echo(f" On archive only: {archive_only_from_sd}")
|
|
229
|
+
typer.echo(f" On neither (not ingested): {on_neither}")
|
|
230
|
+
typer.echo("=" * 70)
|
|
231
|
+
|
|
232
|
+
if on_neither > 0:
|
|
233
|
+
typer.echo("\nSUGGESTED INGEST (missing from both):")
|
|
234
|
+
for d in dates_sorted:
|
|
235
|
+
not_ing = [x for x in by_date[d] if not x[2] and not x[3]]
|
|
236
|
+
if not not_ing:
|
|
237
|
+
continue
|
|
238
|
+
names = sorted(x[0].name for x in not_ing)
|
|
239
|
+
nums = [_extract_number_from_filename(n) for n in names]
|
|
240
|
+
nums = [n for n in nums if n is not None]
|
|
241
|
+
if nums:
|
|
242
|
+
typer.echo(
|
|
243
|
+
f" {d}: --files C{min(nums)}-C{max(nums)} ({len(not_ing)} files)"
|
|
244
|
+
)
|
|
245
|
+
typer.echo("")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def ingest_shoot(
|
|
249
|
+
source_dir: str,
|
|
250
|
+
shoot_name: str,
|
|
251
|
+
laptop_dest: Path,
|
|
252
|
+
archive_dest: Path,
|
|
253
|
+
auto: bool = False,
|
|
254
|
+
force: bool = False,
|
|
255
|
+
skip_laptop: bool = False,
|
|
256
|
+
workspace_dest: Optional[Path] = None,
|
|
257
|
+
split_threshold: int = 0,
|
|
258
|
+
files_filter: Optional[list[str]] = None,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""
|
|
261
|
+
The core logic for the ingest command with date-aware duplicate detection.
|
|
262
|
+
Supports splitting by time gap, skipping laptop backup, and ingesting to workspace.
|
|
263
|
+
"""
|
|
264
|
+
source_path = Path(source_dir)
|
|
265
|
+
if not source_path.exists() or not source_path.is_dir():
|
|
266
|
+
typer.echo(f"Source directory not found: {source_path}", err=True)
|
|
267
|
+
raise typer.Exit(code=1)
|
|
268
|
+
|
|
269
|
+
video_extensions = {
|
|
270
|
+
".mp4",
|
|
271
|
+
".mov",
|
|
272
|
+
".mxf",
|
|
273
|
+
".mts",
|
|
274
|
+
".avi",
|
|
275
|
+
".m4v",
|
|
276
|
+
".braw",
|
|
277
|
+
".r3d",
|
|
278
|
+
".crm",
|
|
279
|
+
}
|
|
280
|
+
all_files: list[Path] = []
|
|
281
|
+
for file_path in source_path.rglob("*"):
|
|
282
|
+
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
|
|
283
|
+
all_files.append(file_path)
|
|
284
|
+
|
|
285
|
+
if not all_files:
|
|
286
|
+
typer.echo("No video files found in the source directory.", err=True)
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
if files_filter:
|
|
290
|
+
files_to_ingest: list[Path] = []
|
|
291
|
+
for pattern in files_filter:
|
|
292
|
+
matching = [f for f in all_files if _matches_pattern(pattern, f.name)]
|
|
293
|
+
files_to_ingest.extend(matching)
|
|
294
|
+
files_to_ingest = list(dict.fromkeys(files_to_ingest))
|
|
295
|
+
|
|
296
|
+
if not files_to_ingest:
|
|
297
|
+
typer.echo(
|
|
298
|
+
f"⚠ No files found matching filter: {', '.join(files_filter)}", err=True
|
|
299
|
+
)
|
|
300
|
+
typer.echo(f" Searched {len(all_files)} file(s) in: {source_path}")
|
|
301
|
+
if len(all_files) <= 10:
|
|
302
|
+
typer.echo(
|
|
303
|
+
f" Available files: {', '.join([f.name for f in all_files[:10]])}"
|
|
304
|
+
)
|
|
305
|
+
else:
|
|
306
|
+
typer.echo(
|
|
307
|
+
f" Sample files: {', '.join([f.name for f in all_files[:5]])}..."
|
|
308
|
+
)
|
|
309
|
+
raise typer.Exit(code=1)
|
|
310
|
+
|
|
311
|
+
typer.echo(
|
|
312
|
+
f"\nFound {len(files_to_ingest)} file(s) matching filter (out of {len(all_files)} total)."
|
|
313
|
+
)
|
|
314
|
+
else:
|
|
315
|
+
files_to_ingest = all_files
|
|
316
|
+
typer.echo(f"\nFound {len(files_to_ingest)} video file(s) to ingest.")
|
|
317
|
+
|
|
318
|
+
files_with_dates = [(f, _get_media_date(f)) for f in files_to_ingest]
|
|
319
|
+
|
|
320
|
+
clusters: list[list[Path]] = []
|
|
321
|
+
if split_threshold > 0:
|
|
322
|
+
clusters = cluster_files_by_date(files_with_dates, split_threshold)
|
|
323
|
+
if len(clusters) > 1:
|
|
324
|
+
typer.echo(
|
|
325
|
+
f"✓ Splitting footage into {len(clusters)} shoots (gap > {split_threshold}h)."
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
clusters = [[f for f, _ in files_with_dates]]
|
|
329
|
+
|
|
330
|
+
archive_raw_root = archive_dest / "Video" / "RAW"
|
|
331
|
+
laptop_index = _build_destination_index(laptop_dest)
|
|
332
|
+
archive_index = _build_destination_index(archive_raw_root)
|
|
333
|
+
typer.echo(
|
|
334
|
+
f"Laptop ingest index: {len(laptop_index)} file(s). Archive index: {len(archive_index)} file(s)."
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
for i, cluster_files in enumerate(clusters):
|
|
338
|
+
if len(clusters) > 1:
|
|
339
|
+
typer.echo(
|
|
340
|
+
f"\n{'=' * 30} PART {i + 1}/{len(clusters)} {'=' * 30}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
cluster_dates = [_get_media_date(f) for f in cluster_files]
|
|
344
|
+
min_dt = min(cluster_dates)
|
|
345
|
+
max_dt = max(cluster_dates)
|
|
346
|
+
min_date = min_dt.date()
|
|
347
|
+
max_date = max_dt.date()
|
|
348
|
+
|
|
349
|
+
typer.echo(f"Date range: {min_date} to {max_date}")
|
|
350
|
+
typer.echo(f"Files in this shoot: {len(cluster_files)}")
|
|
351
|
+
|
|
352
|
+
existing_shoots = _find_existing_shoots(laptop_dest, archive_dest)
|
|
353
|
+
target_shoot_name: Optional[str] = None
|
|
354
|
+
|
|
355
|
+
if auto:
|
|
356
|
+
base_name = format_shoot_name(min_date, max_date, "Ingest")
|
|
357
|
+
if len(clusters) > 1:
|
|
358
|
+
target_shoot_name = f"{base_name}_Part{i+1}"
|
|
359
|
+
else:
|
|
360
|
+
file_date_range = (min_date, max_date)
|
|
361
|
+
matching_shoot = _find_matching_shoot(file_date_range, existing_shoots)
|
|
362
|
+
|
|
363
|
+
if matching_shoot:
|
|
364
|
+
target_shoot_name = matching_shoot
|
|
365
|
+
typer.echo(f"\n✓ Using existing shoot: {target_shoot_name}")
|
|
366
|
+
else:
|
|
367
|
+
target_shoot_name = base_name
|
|
368
|
+
typer.echo(f"\n✓ Creating new shoot: {target_shoot_name}")
|
|
369
|
+
else:
|
|
370
|
+
if not shoot_name:
|
|
371
|
+
typer.echo(
|
|
372
|
+
"Shoot name is required when --auto is not used.", err=True
|
|
373
|
+
)
|
|
374
|
+
raise typer.Exit(code=1)
|
|
375
|
+
|
|
376
|
+
if len(clusters) > 1:
|
|
377
|
+
target_shoot_name = f"{shoot_name}_Part{i+1}"
|
|
378
|
+
else:
|
|
379
|
+
target_shoot_name = shoot_name
|
|
380
|
+
|
|
381
|
+
if len(clusters) == 1:
|
|
382
|
+
if target_shoot_name in existing_shoots:
|
|
383
|
+
shoot_info = existing_shoots[target_shoot_name]
|
|
384
|
+
shoot_start, shoot_end = shoot_info["date_range"]
|
|
385
|
+
if not (shoot_start <= min_date and max_date <= shoot_end):
|
|
386
|
+
typer.echo(
|
|
387
|
+
f"\n⚠ WARNING: Shoot '{target_shoot_name}' exists with date range {shoot_start} to {shoot_end}",
|
|
388
|
+
err=True,
|
|
389
|
+
)
|
|
390
|
+
typer.echo(
|
|
391
|
+
f" But files have date range {min_date} to {max_date}",
|
|
392
|
+
err=True,
|
|
393
|
+
)
|
|
394
|
+
if not force:
|
|
395
|
+
typer.echo(
|
|
396
|
+
" Use --force to proceed anyway.", err=True
|
|
397
|
+
)
|
|
398
|
+
raise typer.Exit(code=1)
|
|
399
|
+
|
|
400
|
+
shoot_exists_info = existing_shoots.get(
|
|
401
|
+
target_shoot_name,
|
|
402
|
+
{
|
|
403
|
+
"in_laptop": False,
|
|
404
|
+
"in_archive": False,
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
shoot_in_laptop = shoot_exists_info.get("in_laptop", False)
|
|
409
|
+
shoot_in_archive = shoot_exists_info.get("in_archive", False)
|
|
410
|
+
|
|
411
|
+
laptop_shoot_dir = laptop_dest / target_shoot_name
|
|
412
|
+
archive_shoot_dir = archive_dest / "Video" / "RAW" / target_shoot_name
|
|
413
|
+
workspace_shoot_dir = None
|
|
414
|
+
if workspace_dest:
|
|
415
|
+
workspace_shoot_dir = workspace_dest / target_shoot_name / "01_Source"
|
|
416
|
+
|
|
417
|
+
copy_to_laptop = not skip_laptop
|
|
418
|
+
copy_to_archive = True
|
|
419
|
+
copy_to_workspace = workspace_dest is not None
|
|
420
|
+
|
|
421
|
+
if skip_laptop:
|
|
422
|
+
typer.echo(" Skipping laptop ingest as requested.")
|
|
423
|
+
|
|
424
|
+
if shoot_in_archive and not shoot_in_laptop and copy_to_laptop:
|
|
425
|
+
typer.echo(
|
|
426
|
+
f"\n✓ Shoot '{target_shoot_name}' exists in archive but not in ingest.",
|
|
427
|
+
err=True,
|
|
428
|
+
)
|
|
429
|
+
typer.echo(
|
|
430
|
+
" Will ingest to laptop only (skipping archive copy since it's already archived)."
|
|
431
|
+
)
|
|
432
|
+
copy_to_archive = False
|
|
433
|
+
|
|
434
|
+
if copy_to_laptop:
|
|
435
|
+
try:
|
|
436
|
+
if not laptop_shoot_dir.exists():
|
|
437
|
+
laptop_shoot_dir.mkdir(parents=True, exist_ok=True)
|
|
438
|
+
except OSError as e:
|
|
439
|
+
if e.errno == 28:
|
|
440
|
+
typer.echo(
|
|
441
|
+
" [WARNING] Laptop storage full. Skipping copy to laptop.",
|
|
442
|
+
err=True,
|
|
443
|
+
)
|
|
444
|
+
copy_to_laptop = False
|
|
445
|
+
else:
|
|
446
|
+
typer.echo(f"Could not create laptop directory: {e}", err=True)
|
|
447
|
+
raise typer.Exit(code=1)
|
|
448
|
+
|
|
449
|
+
if copy_to_archive:
|
|
450
|
+
try:
|
|
451
|
+
if not archive_shoot_dir.exists():
|
|
452
|
+
archive_shoot_dir.mkdir(parents=True, exist_ok=True)
|
|
453
|
+
except Exception as e:
|
|
454
|
+
typer.echo(f"Could not create archive directory: {e}", err=True)
|
|
455
|
+
raise typer.Exit(code=1)
|
|
456
|
+
|
|
457
|
+
if copy_to_workspace:
|
|
458
|
+
try:
|
|
459
|
+
if not workspace_shoot_dir.exists():
|
|
460
|
+
workspace_shoot_dir.mkdir(parents=True, exist_ok=True)
|
|
461
|
+
(workspace_dest / target_shoot_name / "02_Resolve").mkdir(
|
|
462
|
+
exist_ok=True
|
|
463
|
+
)
|
|
464
|
+
(workspace_dest / target_shoot_name / "03_Exports").mkdir(
|
|
465
|
+
exist_ok=True
|
|
466
|
+
)
|
|
467
|
+
(workspace_dest / target_shoot_name / "04_FinalRenders").mkdir(
|
|
468
|
+
exist_ok=True
|
|
469
|
+
)
|
|
470
|
+
(workspace_dest / target_shoot_name / "05_Graded_Selects").mkdir(
|
|
471
|
+
exist_ok=True
|
|
472
|
+
)
|
|
473
|
+
except OSError as e:
|
|
474
|
+
if e.errno == 28:
|
|
475
|
+
typer.echo(
|
|
476
|
+
" [WARNING] Workspace storage full. Skipping copy to workspace.",
|
|
477
|
+
err=True,
|
|
478
|
+
)
|
|
479
|
+
copy_to_workspace = False
|
|
480
|
+
else:
|
|
481
|
+
typer.echo(
|
|
482
|
+
f"Could not create workspace directories: {e}", err=True
|
|
483
|
+
)
|
|
484
|
+
raise typer.Exit(code=1)
|
|
485
|
+
|
|
486
|
+
copied_count = 0
|
|
487
|
+
skipped_count = 0
|
|
488
|
+
error_count = 0
|
|
489
|
+
|
|
490
|
+
with typer.progressbar(
|
|
491
|
+
cluster_files, label=f"Ingesting {target_shoot_name}"
|
|
492
|
+
) as progress:
|
|
493
|
+
for file_path in progress:
|
|
494
|
+
try:
|
|
495
|
+
file_key = (file_path.name, file_path.stat().st_size)
|
|
496
|
+
except OSError:
|
|
497
|
+
file_key = (file_path.name, 0)
|
|
498
|
+
|
|
499
|
+
laptop_dup = (file_key in laptop_index) if copy_to_laptop else False
|
|
500
|
+
archive_dup = (file_key in archive_index) if copy_to_archive else False
|
|
501
|
+
workspace_dup = (
|
|
502
|
+
_is_duplicate(file_path, workspace_shoot_dir)
|
|
503
|
+
if copy_to_workspace
|
|
504
|
+
else False
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
all_dups = True
|
|
508
|
+
if copy_to_laptop and not laptop_dup:
|
|
509
|
+
all_dups = False
|
|
510
|
+
if copy_to_archive and not archive_dup:
|
|
511
|
+
all_dups = False
|
|
512
|
+
if copy_to_workspace and not workspace_dup:
|
|
513
|
+
all_dups = False
|
|
514
|
+
|
|
515
|
+
if all_dups:
|
|
516
|
+
skipped_count += 1
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
file_copied = False
|
|
520
|
+
|
|
521
|
+
if copy_to_laptop and not laptop_dup:
|
|
522
|
+
try:
|
|
523
|
+
typer.echo(f" -> Laptop: {laptop_shoot_dir}")
|
|
524
|
+
if copy_and_verify(file_path, laptop_shoot_dir):
|
|
525
|
+
file_copied = True
|
|
526
|
+
laptop_index.add(file_key)
|
|
527
|
+
else:
|
|
528
|
+
error_count += 1
|
|
529
|
+
except OSError as e:
|
|
530
|
+
if e.errno == 28:
|
|
531
|
+
typer.echo(
|
|
532
|
+
" [WARNING] Laptop storage full. Skipping copy to laptop.",
|
|
533
|
+
err=True,
|
|
534
|
+
)
|
|
535
|
+
copy_to_laptop = False
|
|
536
|
+
else:
|
|
537
|
+
typer.echo(
|
|
538
|
+
f" [ERROR] Copy to laptop failed: {e}", err=True
|
|
539
|
+
)
|
|
540
|
+
error_count += 1
|
|
541
|
+
|
|
542
|
+
if copy_to_archive and not archive_dup:
|
|
543
|
+
try:
|
|
544
|
+
typer.echo(f" -> Archive: {archive_shoot_dir}")
|
|
545
|
+
if copy_and_verify(file_path, archive_shoot_dir):
|
|
546
|
+
file_copied = True
|
|
547
|
+
archive_index.add(file_key)
|
|
548
|
+
else:
|
|
549
|
+
error_count += 1
|
|
550
|
+
except OSError as e:
|
|
551
|
+
if e.errno == 28:
|
|
552
|
+
typer.echo(
|
|
553
|
+
f" [CRITICAL] Archive storage full. Cannot backup {file_path.name}!",
|
|
554
|
+
err=True,
|
|
555
|
+
)
|
|
556
|
+
error_count += 1
|
|
557
|
+
else:
|
|
558
|
+
typer.echo(
|
|
559
|
+
f" [ERROR] Copy to archive failed: {e}", err=True
|
|
560
|
+
)
|
|
561
|
+
error_count += 1
|
|
562
|
+
|
|
563
|
+
if copy_to_workspace and not workspace_dup:
|
|
564
|
+
try:
|
|
565
|
+
typer.echo(f" -> Workspace: {workspace_shoot_dir}")
|
|
566
|
+
if copy_and_verify(file_path, workspace_shoot_dir):
|
|
567
|
+
file_copied = True
|
|
568
|
+
else:
|
|
569
|
+
error_count += 1
|
|
570
|
+
except OSError as e:
|
|
571
|
+
if e.errno == 28:
|
|
572
|
+
typer.echo(
|
|
573
|
+
" [WARNING] Workspace storage full. Skipping copy to workspace.",
|
|
574
|
+
err=True,
|
|
575
|
+
)
|
|
576
|
+
copy_to_workspace = False
|
|
577
|
+
else:
|
|
578
|
+
typer.echo(
|
|
579
|
+
f" [ERROR] Copy to workspace failed: {e}", err=True
|
|
580
|
+
)
|
|
581
|
+
error_count += 1
|
|
582
|
+
|
|
583
|
+
if file_copied:
|
|
584
|
+
copied_count += 1
|
|
585
|
+
|
|
586
|
+
typer.echo(
|
|
587
|
+
f"Finished {target_shoot_name}: {copied_count} copied, {skipped_count} skipped, {error_count} errors."
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
typer.echo(f"\n{'=' * 70}")
|
|
591
|
+
typer.echo("ALL INGEST TASKS COMPLETE")
|
|
592
|
+
typer.echo(f"{'=' * 70}\n")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def prep_shoot(shoot_name: str, laptop_ingest_path: Path, work_ssd_path: Path) -> None:
|
|
596
|
+
"""
|
|
597
|
+
Moves a shoot from the ingest area to the working SSD and creates the project structure.
|
|
598
|
+
Checks for existing files and handles partial preps gracefully.
|
|
599
|
+
"""
|
|
600
|
+
source_shoot_dir = laptop_ingest_path / shoot_name
|
|
601
|
+
if not source_shoot_dir.exists() or not source_shoot_dir.is_dir():
|
|
602
|
+
typer.echo(
|
|
603
|
+
f"Shoot directory not found at ingest location: {source_shoot_dir}",
|
|
604
|
+
err=True,
|
|
605
|
+
)
|
|
606
|
+
raise typer.Exit(code=1)
|
|
607
|
+
|
|
608
|
+
project_dir = work_ssd_path / shoot_name
|
|
609
|
+
source_folder = project_dir / "01_Source"
|
|
610
|
+
resolve_folder = project_dir / "02_Resolve"
|
|
611
|
+
exports_folder = project_dir / "03_Exports"
|
|
612
|
+
final_renders_folder = project_dir / "04_FinalRenders"
|
|
613
|
+
graded_selects_folder = project_dir / "05_Graded_Selects"
|
|
614
|
+
|
|
615
|
+
project_exists = project_dir.exists()
|
|
616
|
+
|
|
617
|
+
if project_exists:
|
|
618
|
+
typer.echo(f"\n⚠ WARNING: Project folder already exists at: {project_dir}")
|
|
619
|
+
typer.echo(" Will only move files that don't already exist in the project.")
|
|
620
|
+
else:
|
|
621
|
+
typer.echo(f"\n✓ Creating new project structure at: {project_dir}")
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
if not project_exists:
|
|
625
|
+
typer.echo("Creating project structure...")
|
|
626
|
+
source_folder.mkdir(parents=True, exist_ok=True)
|
|
627
|
+
resolve_folder.mkdir(exist_ok=True)
|
|
628
|
+
exports_folder.mkdir(exist_ok=True)
|
|
629
|
+
final_renders_folder.mkdir(exist_ok=True)
|
|
630
|
+
graded_selects_folder.mkdir(exist_ok=True)
|
|
631
|
+
except Exception as e:
|
|
632
|
+
typer.echo(f"Could not create project directories on work SSD: {e}", err=True)
|
|
633
|
+
raise typer.Exit(code=1)
|
|
634
|
+
|
|
635
|
+
video_extensions = {".mp4", ".mov", ".mxf", ".mts", ".avi", ".m4v", ".braw"}
|
|
636
|
+
files_to_move = [
|
|
637
|
+
p
|
|
638
|
+
for p in source_shoot_dir.iterdir()
|
|
639
|
+
if p.is_file() and p.suffix.lower() in video_extensions
|
|
640
|
+
]
|
|
641
|
+
|
|
642
|
+
if not files_to_move:
|
|
643
|
+
typer.echo(
|
|
644
|
+
"No video files found in the source shoot directory to move."
|
|
645
|
+
)
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
existing_files: list[str] = []
|
|
649
|
+
if source_folder.exists():
|
|
650
|
+
existing_files = [f.name for f in source_folder.iterdir() if f.is_file()]
|
|
651
|
+
|
|
652
|
+
files_already_exist = 0
|
|
653
|
+
files_to_process: list[Path] = []
|
|
654
|
+
|
|
655
|
+
for f in files_to_move:
|
|
656
|
+
if f.name in existing_files:
|
|
657
|
+
existing_path = source_folder / f.name
|
|
658
|
+
if existing_path.exists():
|
|
659
|
+
try:
|
|
660
|
+
if f.stat().st_size == existing_path.stat().st_size:
|
|
661
|
+
files_already_exist += 1
|
|
662
|
+
continue
|
|
663
|
+
except Exception:
|
|
664
|
+
pass
|
|
665
|
+
files_to_process.append(f)
|
|
666
|
+
|
|
667
|
+
total_files = len(files_to_move)
|
|
668
|
+
|
|
669
|
+
typer.echo(f"\n{'=' * 70}")
|
|
670
|
+
typer.echo("PREP SUMMARY")
|
|
671
|
+
typer.echo(f"{'=' * 70}")
|
|
672
|
+
typer.echo(f"Shoot: {shoot_name}")
|
|
673
|
+
typer.echo(f"Source: {source_shoot_dir}")
|
|
674
|
+
typer.echo(f"Destination: {project_dir}")
|
|
675
|
+
typer.echo(f"Total files in ingest: {total_files}")
|
|
676
|
+
typer.echo(f"Files already in project: {files_already_exist}")
|
|
677
|
+
typer.echo(f"Files to move: {len(files_to_process)}")
|
|
678
|
+
typer.echo(f"{'=' * 70}\n")
|
|
679
|
+
|
|
680
|
+
if files_already_exist > 0:
|
|
681
|
+
typer.echo(
|
|
682
|
+
f"⚠ {files_already_exist}/{total_files} files already exist in project. Skipping duplicates."
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
if not files_to_process:
|
|
686
|
+
typer.echo("All files already exist in project. Nothing to move.")
|
|
687
|
+
typer.echo("\nPrep complete. Project is ready for editing.")
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
typer.echo(f"Moving {len(files_to_process)} video files to {source_folder}...")
|
|
691
|
+
moved_count = 0
|
|
692
|
+
error_count = 0
|
|
693
|
+
|
|
694
|
+
with typer.progressbar(files_to_process, label="Prepping") as progress:
|
|
695
|
+
for f in progress:
|
|
696
|
+
try:
|
|
697
|
+
dest_path = source_folder / f.name
|
|
698
|
+
if dest_path.exists():
|
|
699
|
+
typer.echo(f"\n⚠ SKIPPING (already exists): {f.name}")
|
|
700
|
+
continue
|
|
701
|
+
|
|
702
|
+
shutil.move(str(f), str(source_folder))
|
|
703
|
+
moved_count += 1
|
|
704
|
+
except Exception as e:
|
|
705
|
+
typer.echo(f"\n[ERROR] Could not move {f.name}: {e}", err=True)
|
|
706
|
+
error_count += 1
|
|
707
|
+
|
|
708
|
+
typer.echo(f"\n{'=' * 70}")
|
|
709
|
+
typer.echo("PREP COMPLETE")
|
|
710
|
+
typer.echo(f"{'=' * 70}")
|
|
711
|
+
typer.echo(f"Files moved: {moved_count}")
|
|
712
|
+
typer.echo(f"Files skipped (already exist): {files_already_exist}")
|
|
713
|
+
if error_count > 0:
|
|
714
|
+
typer.echo(f"Errors: {error_count}", err=True)
|
|
715
|
+
typer.echo(f"{'=' * 70}\n")
|
|
716
|
+
typer.echo("Project is ready for editing.")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def pull_shoot(
|
|
720
|
+
shoot_name: str,
|
|
721
|
+
work_ssd_path: Path,
|
|
722
|
+
archive_path: Path,
|
|
723
|
+
source_type: str = "raw",
|
|
724
|
+
files_filter: Optional[list[str]] = None,
|
|
725
|
+
) -> None:
|
|
726
|
+
"""
|
|
727
|
+
Pulls files from archive to the work SSD for editing.
|
|
728
|
+
Creates project structure and copies (doesn't move) files from archive.
|
|
729
|
+
"""
|
|
730
|
+
pull_raw = source_type in ("raw", "both")
|
|
731
|
+
pull_selects = source_type in ("selects", "both")
|
|
732
|
+
|
|
733
|
+
if not pull_raw and not pull_selects:
|
|
734
|
+
typer.echo(
|
|
735
|
+
f"Invalid source type: {source_type}. Must be 'raw', 'selects', or 'both'.",
|
|
736
|
+
err=True,
|
|
737
|
+
)
|
|
738
|
+
raise typer.Exit(code=1)
|
|
739
|
+
|
|
740
|
+
project_dir = work_ssd_path / shoot_name
|
|
741
|
+
source_folder = project_dir / "01_Source"
|
|
742
|
+
resolve_folder = project_dir / "02_Resolve"
|
|
743
|
+
exports_folder = project_dir / "03_Exports"
|
|
744
|
+
final_renders_folder = project_dir / "04_FinalRenders"
|
|
745
|
+
graded_selects_folder = project_dir / "05_Graded_Selects"
|
|
746
|
+
|
|
747
|
+
project_exists = project_dir.exists()
|
|
748
|
+
|
|
749
|
+
if project_exists:
|
|
750
|
+
typer.echo(f"\n✓ Project folder already exists at: {project_dir}")
|
|
751
|
+
typer.echo(" Will only copy files that don't already exist in the project.")
|
|
752
|
+
else:
|
|
753
|
+
typer.echo(f"\n✓ Creating new project structure at: {project_dir}")
|
|
754
|
+
|
|
755
|
+
try:
|
|
756
|
+
if not project_exists:
|
|
757
|
+
typer.echo("Creating project structure...")
|
|
758
|
+
source_folder.mkdir(parents=True, exist_ok=True)
|
|
759
|
+
resolve_folder.mkdir(exist_ok=True)
|
|
760
|
+
exports_folder.mkdir(exist_ok=True)
|
|
761
|
+
final_renders_folder.mkdir(exist_ok=True)
|
|
762
|
+
graded_selects_folder.mkdir(exist_ok=True)
|
|
763
|
+
except Exception as e:
|
|
764
|
+
typer.echo(f"Could not create project directories on work SSD: {e}", err=True)
|
|
765
|
+
raise typer.Exit(code=1)
|
|
766
|
+
|
|
767
|
+
video_extensions = {
|
|
768
|
+
".mp4",
|
|
769
|
+
".mov",
|
|
770
|
+
".mxf",
|
|
771
|
+
".mts",
|
|
772
|
+
".avi",
|
|
773
|
+
".m4v",
|
|
774
|
+
".braw",
|
|
775
|
+
".r3d",
|
|
776
|
+
".crm",
|
|
777
|
+
}
|
|
778
|
+
total_copied = 0
|
|
779
|
+
total_skipped = 0
|
|
780
|
+
total_errors = 0
|
|
781
|
+
|
|
782
|
+
sources_to_pull: list[tuple[str, Path, Path]] = []
|
|
783
|
+
if pull_raw:
|
|
784
|
+
archive_raw_dir = archive_path / "Video" / "RAW" / shoot_name
|
|
785
|
+
if archive_raw_dir.exists() and archive_raw_dir.is_dir():
|
|
786
|
+
sources_to_pull.append(("RAW", archive_raw_dir, source_folder))
|
|
787
|
+
else:
|
|
788
|
+
typer.echo(
|
|
789
|
+
f"⚠ Warning: RAW directory not found: {archive_raw_dir}", err=True
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
if pull_selects:
|
|
793
|
+
archive_selects_dir = archive_path / "Video" / "Graded_Selects" / shoot_name
|
|
794
|
+
if archive_selects_dir.exists() and archive_selects_dir.is_dir():
|
|
795
|
+
sources_to_pull.append(
|
|
796
|
+
("Graded Selects", archive_selects_dir, graded_selects_folder)
|
|
797
|
+
)
|
|
798
|
+
else:
|
|
799
|
+
typer.echo(
|
|
800
|
+
f"⚠ Warning: Graded Selects directory not found: {archive_selects_dir}",
|
|
801
|
+
err=True,
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
if not sources_to_pull:
|
|
805
|
+
typer.echo(
|
|
806
|
+
f"No source directories found in archive for shoot '{shoot_name}'.",
|
|
807
|
+
err=True,
|
|
808
|
+
)
|
|
809
|
+
raise typer.Exit(code=1)
|
|
810
|
+
|
|
811
|
+
for source_label, archive_dir, dest_folder in sources_to_pull:
|
|
812
|
+
typer.echo(f"\n{'=' * 70}")
|
|
813
|
+
typer.echo(f"PULLING FROM {source_label.upper()}")
|
|
814
|
+
typer.echo(f"{'=' * 70}")
|
|
815
|
+
|
|
816
|
+
all_files = [
|
|
817
|
+
p
|
|
818
|
+
for p in archive_dir.iterdir()
|
|
819
|
+
if p.is_file() and p.suffix.lower() in video_extensions
|
|
820
|
+
]
|
|
821
|
+
|
|
822
|
+
if not all_files:
|
|
823
|
+
typer.echo(f"No video files found in {source_label} directory: {archive_dir}")
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
if files_filter:
|
|
827
|
+
files_to_copy: list[Path] = []
|
|
828
|
+
for pattern in files_filter:
|
|
829
|
+
matching = [f for f in all_files if _matches_pattern(pattern, f.name)]
|
|
830
|
+
files_to_copy.extend(matching)
|
|
831
|
+
files_to_copy = list(dict.fromkeys(files_to_copy))
|
|
832
|
+
else:
|
|
833
|
+
files_to_copy = all_files
|
|
834
|
+
|
|
835
|
+
if not files_to_copy:
|
|
836
|
+
if files_filter:
|
|
837
|
+
typer.echo(
|
|
838
|
+
f"⚠ No files found matching filter in {source_label}: {', '.join(files_filter)}"
|
|
839
|
+
)
|
|
840
|
+
typer.echo(f" Searched {len(all_files)} file(s) in: {archive_dir}")
|
|
841
|
+
if len(all_files) <= 10:
|
|
842
|
+
typer.echo(
|
|
843
|
+
f" Available files: {', '.join([f.name for f in all_files[:10]])}"
|
|
844
|
+
)
|
|
845
|
+
else:
|
|
846
|
+
typer.echo(
|
|
847
|
+
f" Sample files: {', '.join([f.name for f in all_files[:5]])}..."
|
|
848
|
+
)
|
|
849
|
+
else:
|
|
850
|
+
typer.echo(f"No video files found in {source_label} directory.")
|
|
851
|
+
continue
|
|
852
|
+
|
|
853
|
+
existing_files: list[str] = []
|
|
854
|
+
if dest_folder.exists():
|
|
855
|
+
existing_files = [f.name for f in dest_folder.iterdir() if f.is_file()]
|
|
856
|
+
|
|
857
|
+
files_already_exist = 0
|
|
858
|
+
files_to_process: list[Path] = []
|
|
859
|
+
|
|
860
|
+
for f in files_to_copy:
|
|
861
|
+
if f.name in existing_files:
|
|
862
|
+
existing_path = dest_folder / f.name
|
|
863
|
+
if existing_path.exists():
|
|
864
|
+
try:
|
|
865
|
+
if f.stat().st_size == existing_path.stat().st_size:
|
|
866
|
+
files_already_exist += 1
|
|
867
|
+
continue
|
|
868
|
+
except Exception:
|
|
869
|
+
pass
|
|
870
|
+
files_to_process.append(f)
|
|
871
|
+
|
|
872
|
+
total_files = len(files_to_copy)
|
|
873
|
+
|
|
874
|
+
typer.echo(f"Archive source: {archive_dir}")
|
|
875
|
+
typer.echo(f"Destination: {dest_folder}")
|
|
876
|
+
typer.echo(f"Total files available: {total_files}")
|
|
877
|
+
typer.echo(f"Files already exist: {files_already_exist}")
|
|
878
|
+
typer.echo(f"Files to copy: {len(files_to_process)}")
|
|
879
|
+
if files_filter:
|
|
880
|
+
typer.echo(f"Filter applied: {', '.join(files_filter)}")
|
|
881
|
+
|
|
882
|
+
if files_already_exist > 0:
|
|
883
|
+
typer.echo(
|
|
884
|
+
f"⚠ {files_already_exist}/{total_files} files already exist. Skipping duplicates."
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
if not files_to_process:
|
|
888
|
+
typer.echo(f"All files already exist in {dest_folder}. Skipping.")
|
|
889
|
+
continue
|
|
890
|
+
|
|
891
|
+
typer.echo(
|
|
892
|
+
f"\nCopying {len(files_to_process)} files from {source_label}..."
|
|
893
|
+
)
|
|
894
|
+
copied_count = 0
|
|
895
|
+
error_count = 0
|
|
896
|
+
|
|
897
|
+
with typer.progressbar(
|
|
898
|
+
files_to_process, label=f"Pulling {source_label}"
|
|
899
|
+
) as progress:
|
|
900
|
+
for f in progress:
|
|
901
|
+
try:
|
|
902
|
+
dest_path = dest_folder / f.name
|
|
903
|
+
if dest_path.exists():
|
|
904
|
+
typer.echo(f"\n⚠ SKIPPING (already exists): {f.name}")
|
|
905
|
+
total_skipped += 1
|
|
906
|
+
continue
|
|
907
|
+
|
|
908
|
+
copy_and_verify(f, dest_folder)
|
|
909
|
+
copied_count += 1
|
|
910
|
+
total_copied += 1
|
|
911
|
+
except Exception as e:
|
|
912
|
+
typer.echo(
|
|
913
|
+
f"\n[ERROR] Could not copy {f.name}: {e}", err=True
|
|
914
|
+
)
|
|
915
|
+
error_count += 1
|
|
916
|
+
total_errors += 1
|
|
917
|
+
|
|
918
|
+
typer.echo(
|
|
919
|
+
f"✓ {source_label}: {copied_count} copied, {files_already_exist} skipped"
|
|
920
|
+
)
|
|
921
|
+
total_skipped += files_already_exist
|
|
922
|
+
|
|
923
|
+
typer.echo(f"\n{'=' * 70}")
|
|
924
|
+
typer.echo("PULL COMPLETE")
|
|
925
|
+
typer.echo(f"{'=' * 70}")
|
|
926
|
+
typer.echo(f"Total files copied: {total_copied}")
|
|
927
|
+
typer.echo(f"Total files skipped: {total_skipped}")
|
|
928
|
+
if total_errors > 0:
|
|
929
|
+
typer.echo(f"Errors: {total_errors}", err=True)
|
|
930
|
+
typer.echo(f"{'=' * 70}\n")
|
|
931
|
+
typer.echo("Project is ready for editing.")
|
|
932
|
+
|