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.
@@ -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
+