vflow-cli 0.1.4__tar.gz → 0.1.5__tar.gz
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_cli-0.1.4 → vflow_cli-0.1.5}/PKG-INFO +1 -1
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/pyproject.toml +1 -1
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/actions.py +3 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/backup_service.py +134 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/ingest_service.py +194 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/main.py +51 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/PKG-INFO +1 -1
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/README.md +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/setup.cfg +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/__init__.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/config.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/core/__init__.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/core/date_utils.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/core/fs_ops.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/core/media_ops.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/core/patterns.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/delivery_service.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/utils_date.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/SOURCES.txt +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/dependency_links.txt +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/entry_points.txt +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/requires.txt +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/top_level.txt +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_braw_and_prores_extensions.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_backup_and_verify.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_ingest_and_pull_filters.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_ingest_report.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_list_backups.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_restore_folder.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_verify_backup_mirror.py +0 -0
- {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_patterns_and_duplicates.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "vflow-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.5"
|
|
4
4
|
description = "The v-flow CLI and Claude skills bundle for automating media backup and processing workflows for videographers."
|
|
5
5
|
authors = [{ name = "Kaung", email = "kaungzinye@gmail.com" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -12,6 +12,8 @@ main.py and tests import ``actions.*``; the real implementations live in:
|
|
|
12
12
|
from .ingest_service import ( # noqa: F401
|
|
13
13
|
ingest_report,
|
|
14
14
|
ingest_shoot,
|
|
15
|
+
photo_ingest,
|
|
16
|
+
card_report,
|
|
15
17
|
prep_shoot,
|
|
16
18
|
pull_shoot,
|
|
17
19
|
)
|
|
@@ -25,6 +27,7 @@ from .delivery_service import ( # noqa: F401
|
|
|
25
27
|
from .backup_service import ( # noqa: F401
|
|
26
28
|
consolidate_files,
|
|
27
29
|
verify_backup,
|
|
30
|
+
card_verify,
|
|
28
31
|
list_backups,
|
|
29
32
|
restore_folder,
|
|
30
33
|
list_duplicates,
|
|
@@ -588,6 +588,140 @@ def restore_folder(
|
|
|
588
588
|
typer.echo(f"Errors: {errors}", err=True)
|
|
589
589
|
|
|
590
590
|
|
|
591
|
+
def card_verify(
|
|
592
|
+
source_dir: str,
|
|
593
|
+
archive_path: Path,
|
|
594
|
+
photo_shoot: Optional[str] = None,
|
|
595
|
+
) -> None:
|
|
596
|
+
"""
|
|
597
|
+
Verifies card contents against the archive before formatting.
|
|
598
|
+
|
|
599
|
+
Videos are checked archive-wide (Video/RAW) by name+size.
|
|
600
|
+
Photos are checked against the specific Photo/RAW/<photo_shoot> folder only,
|
|
601
|
+
to avoid Sony filename-recycling false positives.
|
|
602
|
+
"""
|
|
603
|
+
source_path = Path(source_dir)
|
|
604
|
+
if not source_path.exists() or not source_path.is_dir():
|
|
605
|
+
typer.echo(f"Source directory not found: {source_path}", err=True)
|
|
606
|
+
raise typer.Exit(code=1)
|
|
607
|
+
|
|
608
|
+
video_folder = source_path / "private" / "M4ROOT" / "CLIP"
|
|
609
|
+
photo_folder = source_path / "DCIM" / "100MSDCF"
|
|
610
|
+
|
|
611
|
+
video_extensions = {".mp4", ".mov", ".mxf", ".mts", ".avi", ".m4v", ".braw", ".r3d", ".crm"}
|
|
612
|
+
photo_extensions = {".arw", ".cr2", ".cr3", ".nef", ".dng", ".orf", ".rw2"}
|
|
613
|
+
|
|
614
|
+
has_videos = video_folder.exists() and video_folder.is_dir()
|
|
615
|
+
has_photos = photo_folder.exists() and photo_folder.is_dir()
|
|
616
|
+
|
|
617
|
+
typer.echo("\n" + "=" * 70)
|
|
618
|
+
typer.echo("CARD VERIFY")
|
|
619
|
+
typer.echo("=" * 70)
|
|
620
|
+
typer.echo(f"Card: {source_path}")
|
|
621
|
+
|
|
622
|
+
overall_pass = True
|
|
623
|
+
|
|
624
|
+
# VIDEO VERIFICATION
|
|
625
|
+
if has_videos:
|
|
626
|
+
video_files: list[Path] = [
|
|
627
|
+
f for f in video_folder.rglob("*")
|
|
628
|
+
if f.is_file() and f.suffix.lower() in video_extensions
|
|
629
|
+
]
|
|
630
|
+
|
|
631
|
+
archive_video_raw = archive_path / "Video" / "RAW"
|
|
632
|
+
archive_video_index: set[tuple[str, int]] = set()
|
|
633
|
+
if archive_video_raw.exists():
|
|
634
|
+
for f in archive_video_raw.rglob("*"):
|
|
635
|
+
if f.is_file() and f.suffix.lower() in video_extensions:
|
|
636
|
+
try:
|
|
637
|
+
archive_video_index.add((f.name, f.stat().st_size))
|
|
638
|
+
except (OSError, FileNotFoundError):
|
|
639
|
+
pass
|
|
640
|
+
|
|
641
|
+
missing_videos: list[str] = []
|
|
642
|
+
for f in video_files:
|
|
643
|
+
try:
|
|
644
|
+
key = (f.name, f.stat().st_size)
|
|
645
|
+
except (OSError, FileNotFoundError):
|
|
646
|
+
missing_videos.append(f.name)
|
|
647
|
+
continue
|
|
648
|
+
if key not in archive_video_index:
|
|
649
|
+
missing_videos.append(f.name)
|
|
650
|
+
|
|
651
|
+
video_pass = len(missing_videos) == 0
|
|
652
|
+
if not video_pass:
|
|
653
|
+
overall_pass = False
|
|
654
|
+
|
|
655
|
+
typer.echo(f"\nVIDEOS (checked archive-wide under Video/RAW)")
|
|
656
|
+
typer.echo(f" On card: {len(video_files)}")
|
|
657
|
+
typer.echo(f" In archive: {len(video_files) - len(missing_videos)}")
|
|
658
|
+
typer.echo(f" Missing: {len(missing_videos)}")
|
|
659
|
+
typer.echo(f" Result: {'PASS' if video_pass else 'FAIL'}")
|
|
660
|
+
if missing_videos:
|
|
661
|
+
typer.echo(" Missing files:")
|
|
662
|
+
for name in missing_videos[:20]:
|
|
663
|
+
typer.echo(f" - {name}")
|
|
664
|
+
if len(missing_videos) > 20:
|
|
665
|
+
typer.echo(f" ... and {len(missing_videos) - 20} more.")
|
|
666
|
+
else:
|
|
667
|
+
typer.echo(f"\nVIDEOS — card folder not found, skipping ({video_folder})")
|
|
668
|
+
|
|
669
|
+
# PHOTO VERIFICATION
|
|
670
|
+
if has_photos:
|
|
671
|
+
if not photo_shoot:
|
|
672
|
+
typer.echo("\nPHOTOS — skipped (no --photo-shoot provided)")
|
|
673
|
+
else:
|
|
674
|
+
photo_files: list[Path] = [
|
|
675
|
+
f for f in photo_folder.rglob("*")
|
|
676
|
+
if f.is_file() and f.suffix.lower() in photo_extensions
|
|
677
|
+
]
|
|
678
|
+
|
|
679
|
+
shoot_photo_dir = archive_path / "Photo" / "RAW" / photo_shoot
|
|
680
|
+
shoot_photo_index: set[tuple[str, int]] = set()
|
|
681
|
+
if shoot_photo_dir.exists():
|
|
682
|
+
for f in shoot_photo_dir.iterdir():
|
|
683
|
+
if f.is_file() and f.suffix.lower() in photo_extensions:
|
|
684
|
+
try:
|
|
685
|
+
shoot_photo_index.add((f.name, f.stat().st_size))
|
|
686
|
+
except (OSError, FileNotFoundError):
|
|
687
|
+
pass
|
|
688
|
+
else:
|
|
689
|
+
typer.echo(f"\nPHOTOS — shoot folder not found: {shoot_photo_dir}", err=True)
|
|
690
|
+
overall_pass = False
|
|
691
|
+
|
|
692
|
+
missing_photos: list[str] = []
|
|
693
|
+
for f in photo_files:
|
|
694
|
+
try:
|
|
695
|
+
key = (f.name, f.stat().st_size)
|
|
696
|
+
except (OSError, FileNotFoundError):
|
|
697
|
+
missing_photos.append(f.name)
|
|
698
|
+
continue
|
|
699
|
+
if key not in shoot_photo_index:
|
|
700
|
+
missing_photos.append(f.name)
|
|
701
|
+
|
|
702
|
+
photo_pass = len(missing_photos) == 0
|
|
703
|
+
if not photo_pass:
|
|
704
|
+
overall_pass = False
|
|
705
|
+
|
|
706
|
+
typer.echo(f"\nPHOTOS (checked against Photo/RAW/{photo_shoot} only)")
|
|
707
|
+
typer.echo(f" On card: {len(photo_files)}")
|
|
708
|
+
typer.echo(f" In shoot: {len(photo_files) - len(missing_photos)}")
|
|
709
|
+
typer.echo(f" Missing: {len(missing_photos)}")
|
|
710
|
+
typer.echo(f" Result: {'PASS' if photo_pass else 'FAIL'}")
|
|
711
|
+
if missing_photos:
|
|
712
|
+
typer.echo(" Missing files:")
|
|
713
|
+
for name in missing_photos[:20]:
|
|
714
|
+
typer.echo(f" - {name}")
|
|
715
|
+
if len(missing_photos) > 20:
|
|
716
|
+
typer.echo(f" ... and {len(missing_photos) - 20} more.")
|
|
717
|
+
else:
|
|
718
|
+
typer.echo(f"\nPHOTOS — card folder not found, skipping ({photo_folder})")
|
|
719
|
+
|
|
720
|
+
typer.echo("\n" + "=" * 70)
|
|
721
|
+
typer.echo(f"OVERALL: {'PASS — safe to format card' if overall_pass else 'FAIL — do not format, files missing from archive'}")
|
|
722
|
+
typer.echo("=" * 70 + "\n")
|
|
723
|
+
|
|
724
|
+
|
|
591
725
|
def list_duplicates(
|
|
592
726
|
root: Path, max_age_hours: Optional[int] = None
|
|
593
727
|
) -> list[tuple[tuple[str, int], list[Path]]]:
|
|
@@ -592,6 +592,200 @@ def ingest_shoot(
|
|
|
592
592
|
typer.echo(f"{'=' * 70}\n")
|
|
593
593
|
|
|
594
594
|
|
|
595
|
+
def photo_ingest(
|
|
596
|
+
source_dir: str,
|
|
597
|
+
shoot_name: str,
|
|
598
|
+
archive_path: Path,
|
|
599
|
+
) -> None:
|
|
600
|
+
"""
|
|
601
|
+
Copies new RAW photo files from source_dir to archive_path/Photo/RAW/<shoot_name>.
|
|
602
|
+
Skips files that already exist in that specific shoot folder by name+size.
|
|
603
|
+
Preserves timestamps using shutil.copy2.
|
|
604
|
+
"""
|
|
605
|
+
source_path = Path(source_dir)
|
|
606
|
+
if not source_path.exists() or not source_path.is_dir():
|
|
607
|
+
typer.echo(f"Source directory not found: {source_path}", err=True)
|
|
608
|
+
raise typer.Exit(code=1)
|
|
609
|
+
|
|
610
|
+
photo_extensions = {".arw", ".cr2", ".cr3", ".nef", ".dng", ".orf", ".rw2"}
|
|
611
|
+
|
|
612
|
+
all_files: list[Path] = []
|
|
613
|
+
for file_path in source_path.rglob("*"):
|
|
614
|
+
if file_path.is_file() and file_path.suffix.lower() in photo_extensions:
|
|
615
|
+
all_files.append(file_path)
|
|
616
|
+
|
|
617
|
+
if not all_files:
|
|
618
|
+
typer.echo("No RAW photo files found in source directory.", err=True)
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
shoot_dir = archive_path / "Photo" / "RAW" / shoot_name
|
|
622
|
+
try:
|
|
623
|
+
shoot_dir.mkdir(parents=True, exist_ok=True)
|
|
624
|
+
except Exception as e:
|
|
625
|
+
typer.echo(f"Could not create shoot directory: {e}", err=True)
|
|
626
|
+
raise typer.Exit(code=1)
|
|
627
|
+
|
|
628
|
+
# Build index of files already in this specific shoot folder only (not archive-wide)
|
|
629
|
+
shoot_index: set[tuple[str, int]] = set()
|
|
630
|
+
for f in shoot_dir.iterdir():
|
|
631
|
+
if f.is_file() and f.suffix.lower() in photo_extensions:
|
|
632
|
+
try:
|
|
633
|
+
shoot_index.add((f.name, f.stat().st_size))
|
|
634
|
+
except (OSError, FileNotFoundError):
|
|
635
|
+
pass
|
|
636
|
+
|
|
637
|
+
typer.echo(f"\nSource: {source_path}")
|
|
638
|
+
typer.echo(f"Destination: {shoot_dir}")
|
|
639
|
+
typer.echo(f"Found: {len(all_files)} RAW file(s) on card")
|
|
640
|
+
typer.echo(f"Already in shoot folder: {len(shoot_index)} file(s)")
|
|
641
|
+
|
|
642
|
+
copied_count = 0
|
|
643
|
+
skipped_count = 0
|
|
644
|
+
error_count = 0
|
|
645
|
+
|
|
646
|
+
with typer.progressbar(all_files, label=f"Photo ingest {shoot_name}") as progress:
|
|
647
|
+
for file_path in progress:
|
|
648
|
+
try:
|
|
649
|
+
size = file_path.stat().st_size
|
|
650
|
+
except (OSError, FileNotFoundError):
|
|
651
|
+
error_count += 1
|
|
652
|
+
continue
|
|
653
|
+
|
|
654
|
+
key = (file_path.name, size)
|
|
655
|
+
if key in shoot_index:
|
|
656
|
+
skipped_count += 1
|
|
657
|
+
continue
|
|
658
|
+
|
|
659
|
+
dest_file = shoot_dir / file_path.name
|
|
660
|
+
try:
|
|
661
|
+
shutil.copy2(str(file_path), str(dest_file))
|
|
662
|
+
shoot_index.add(key)
|
|
663
|
+
copied_count += 1
|
|
664
|
+
except Exception as e:
|
|
665
|
+
typer.echo(f"\n[ERROR] Could not copy {file_path.name}: {e}", err=True)
|
|
666
|
+
error_count += 1
|
|
667
|
+
|
|
668
|
+
typer.echo(f"\n{'=' * 70}")
|
|
669
|
+
typer.echo("PHOTO INGEST COMPLETE")
|
|
670
|
+
typer.echo(f"{'=' * 70}")
|
|
671
|
+
typer.echo(f"Copied: {copied_count}")
|
|
672
|
+
typer.echo(f"Skipped: {skipped_count} (already exist in shoot folder)")
|
|
673
|
+
typer.echo(f"Errors: {error_count}")
|
|
674
|
+
typer.echo(f"{'=' * 70}\n")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def card_report(
|
|
678
|
+
source_dir: str,
|
|
679
|
+
archive_path: Path,
|
|
680
|
+
) -> None:
|
|
681
|
+
"""
|
|
682
|
+
Reports what's on a card. Auto-detects video (private/M4ROOT/CLIP) and photo
|
|
683
|
+
(DCIM/100MSDCF) folders, groups files by date, and shows first/last filename,
|
|
684
|
+
count, and whether videos are already in the archive.
|
|
685
|
+
"""
|
|
686
|
+
source_path = Path(source_dir)
|
|
687
|
+
if not source_path.exists() or not source_path.is_dir():
|
|
688
|
+
typer.echo(f"Source directory not found: {source_path}", err=True)
|
|
689
|
+
raise typer.Exit(code=1)
|
|
690
|
+
|
|
691
|
+
video_folder = source_path / "private" / "M4ROOT" / "CLIP"
|
|
692
|
+
photo_folder = source_path / "DCIM" / "100MSDCF"
|
|
693
|
+
|
|
694
|
+
video_extensions = {".mp4", ".mov", ".mxf", ".mts", ".avi", ".m4v", ".braw", ".r3d", ".crm"}
|
|
695
|
+
photo_extensions = {".arw", ".cr2", ".cr3", ".nef", ".dng", ".orf", ".rw2"}
|
|
696
|
+
|
|
697
|
+
typer.echo("\n" + "=" * 70)
|
|
698
|
+
typer.echo("CARD REPORT")
|
|
699
|
+
typer.echo("=" * 70)
|
|
700
|
+
typer.echo(f"Card: {source_path}")
|
|
701
|
+
|
|
702
|
+
# Build archive-wide video index for presence check
|
|
703
|
+
archive_video_raw = archive_path / "Video" / "RAW"
|
|
704
|
+
archive_video_index: set[tuple[str, int]] = set()
|
|
705
|
+
if archive_video_raw.exists():
|
|
706
|
+
for f in archive_video_raw.rglob("*"):
|
|
707
|
+
if f.is_file() and f.suffix.lower() in video_extensions:
|
|
708
|
+
try:
|
|
709
|
+
archive_video_index.add((f.name, f.stat().st_size))
|
|
710
|
+
except (OSError, FileNotFoundError):
|
|
711
|
+
pass
|
|
712
|
+
|
|
713
|
+
from collections import defaultdict
|
|
714
|
+
|
|
715
|
+
# VIDEO SECTION
|
|
716
|
+
if video_folder.exists() and video_folder.is_dir():
|
|
717
|
+
video_files: list[Path] = []
|
|
718
|
+
for f in video_folder.rglob("*"):
|
|
719
|
+
if f.is_file() and f.suffix.lower() in video_extensions:
|
|
720
|
+
video_files.append(f)
|
|
721
|
+
|
|
722
|
+
typer.echo(f"\nVIDEOS ({video_folder})")
|
|
723
|
+
typer.echo("-" * 70)
|
|
724
|
+
|
|
725
|
+
if not video_files:
|
|
726
|
+
typer.echo(" No video files found.")
|
|
727
|
+
else:
|
|
728
|
+
by_date: dict = defaultdict(list)
|
|
729
|
+
for f in video_files:
|
|
730
|
+
try:
|
|
731
|
+
d = _get_media_date(f).date()
|
|
732
|
+
by_date[d].append(f)
|
|
733
|
+
except Exception:
|
|
734
|
+
continue
|
|
735
|
+
|
|
736
|
+
for d in sorted(by_date.keys()):
|
|
737
|
+
day_files = sorted(by_date[d], key=lambda f: f.name)
|
|
738
|
+
count = len(day_files)
|
|
739
|
+
first = day_files[0].name
|
|
740
|
+
last = day_files[-1].name
|
|
741
|
+
on_archive = sum(
|
|
742
|
+
1 for f in day_files
|
|
743
|
+
if (f.name, f.stat().st_size) in archive_video_index
|
|
744
|
+
)
|
|
745
|
+
hdd_label = f"{on_archive}/{count} on HDD" if on_archive > 0 else "NOT on HDD"
|
|
746
|
+
if first == last:
|
|
747
|
+
typer.echo(f" {d} {count} file(s) [{first}] {hdd_label}")
|
|
748
|
+
else:
|
|
749
|
+
typer.echo(f" {d} {count} file(s) [{first} .. {last}] {hdd_label}")
|
|
750
|
+
else:
|
|
751
|
+
typer.echo(f"\nVIDEOS — folder not found ({video_folder})")
|
|
752
|
+
|
|
753
|
+
# PHOTO SECTION
|
|
754
|
+
if photo_folder.exists() and photo_folder.is_dir():
|
|
755
|
+
photo_files: list[Path] = []
|
|
756
|
+
for f in photo_folder.rglob("*"):
|
|
757
|
+
if f.is_file() and f.suffix.lower() in photo_extensions:
|
|
758
|
+
photo_files.append(f)
|
|
759
|
+
|
|
760
|
+
typer.echo(f"\nPHOTOS ({photo_folder})")
|
|
761
|
+
typer.echo("-" * 70)
|
|
762
|
+
|
|
763
|
+
if not photo_files:
|
|
764
|
+
typer.echo(" No RAW photo files found.")
|
|
765
|
+
else:
|
|
766
|
+
by_date_p: dict = defaultdict(list)
|
|
767
|
+
for f in photo_files:
|
|
768
|
+
try:
|
|
769
|
+
d = _get_media_date(f).date()
|
|
770
|
+
by_date_p[d].append(f)
|
|
771
|
+
except Exception:
|
|
772
|
+
continue
|
|
773
|
+
|
|
774
|
+
for d in sorted(by_date_p.keys()):
|
|
775
|
+
day_files = sorted(by_date_p[d], key=lambda f: f.name)
|
|
776
|
+
count = len(day_files)
|
|
777
|
+
first = day_files[0].name
|
|
778
|
+
last = day_files[-1].name
|
|
779
|
+
if first == last:
|
|
780
|
+
typer.echo(f" {d} {count} file(s) [{first}]")
|
|
781
|
+
else:
|
|
782
|
+
typer.echo(f" {d} {count} file(s) [{first} .. {last}]")
|
|
783
|
+
else:
|
|
784
|
+
typer.echo(f"\nPHOTOS — folder not found ({photo_folder})")
|
|
785
|
+
|
|
786
|
+
typer.echo("\n" + "=" * 70 + "\n")
|
|
787
|
+
|
|
788
|
+
|
|
595
789
|
def prep_shoot(shoot_name: str, laptop_ingest_path: Path, work_ssd_path: Path) -> None:
|
|
596
790
|
"""
|
|
597
791
|
Moves a shoot from the ingest area to the working SSD and creates the project structure.
|
|
@@ -63,6 +63,57 @@ def ingest_report_cmd(
|
|
|
63
63
|
laptop_dest = config.get_location(app_config, "laptop")
|
|
64
64
|
actions.ingest_report(source, archive_dest, laptop_path=laptop_dest, priority_day=priority_day, priority_month=priority_month)
|
|
65
65
|
|
|
66
|
+
@app.command("photo-ingest")
|
|
67
|
+
def photo_ingest_cmd(
|
|
68
|
+
source: str = typer.Option(..., "--source", "-s", help="Folder containing RAW photo files (e.g., '/Volumes/Untitled/DCIM/100MSDCF')."),
|
|
69
|
+
shoot: str = typer.Option(..., "--shoot", "-n", help="Name of the shoot folder to create/add to in Photo/RAW (e.g., 'Iceland Trip')."),
|
|
70
|
+
):
|
|
71
|
+
"""
|
|
72
|
+
Copies new RAW photos from a card or folder into the photo archive.
|
|
73
|
+
|
|
74
|
+
Files are compared against the specific shoot folder only (not archive-wide) to
|
|
75
|
+
avoid Sony filename-recycling false positives. Timestamps are preserved.
|
|
76
|
+
Supported formats: ARW, CR2, CR3, NEF, DNG, ORF, RW2.
|
|
77
|
+
"""
|
|
78
|
+
app_config = config.load_config()
|
|
79
|
+
archive_dest = config.get_location(app_config, "archive_hdd")
|
|
80
|
+
actions.photo_ingest(source, shoot, archive_dest)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("card-report")
|
|
84
|
+
def card_report_cmd(
|
|
85
|
+
source: str = typer.Option(..., "--source", "-s", help="Card root directory (e.g., '/Volumes/Untitled')."),
|
|
86
|
+
):
|
|
87
|
+
"""
|
|
88
|
+
Shows what's on a card, grouped by date.
|
|
89
|
+
|
|
90
|
+
Auto-detects the video folder (private/M4ROOT/CLIP) and photo folder (DCIM/100MSDCF).
|
|
91
|
+
For each section, shows date, first/last filename, count, and whether video files
|
|
92
|
+
are already in the archive.
|
|
93
|
+
"""
|
|
94
|
+
app_config = config.load_config()
|
|
95
|
+
archive_dest = config.get_location(app_config, "archive_hdd")
|
|
96
|
+
actions.card_report(source, archive_dest)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command("card-verify")
|
|
100
|
+
def card_verify_cmd(
|
|
101
|
+
source: str = typer.Option(..., "--source", "-s", help="Card root directory (e.g., '/Volumes/Untitled')."),
|
|
102
|
+
photo_shoot: Optional[str] = typer.Option(None, "--photo-shoot", help="Name of the photo shoot in Photo/RAW to verify photos against (required if card has photos)."),
|
|
103
|
+
):
|
|
104
|
+
"""
|
|
105
|
+
Verifies card contents are safely in the archive before formatting.
|
|
106
|
+
|
|
107
|
+
Videos are checked archive-wide (Video/RAW) by name+size.
|
|
108
|
+
Photos are checked against a specific shoot folder only (Photo/RAW/<photo-shoot>),
|
|
109
|
+
avoiding Sony filename-recycling false positives across shoots.
|
|
110
|
+
Reports PASS or FAIL separately for videos and photos.
|
|
111
|
+
"""
|
|
112
|
+
app_config = config.load_config()
|
|
113
|
+
archive_dest = config.get_location(app_config, "archive_hdd")
|
|
114
|
+
actions.card_verify(source, archive_dest, photo_shoot=photo_shoot)
|
|
115
|
+
|
|
116
|
+
|
|
66
117
|
@app.command("list-duplicates")
|
|
67
118
|
def list_duplicates_cmd(
|
|
68
119
|
location: str = typer.Option("archive", "--location", "-l", help="Where to scan: 'archive', 'laptop', or 'both'"),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|