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.
Files changed (31) hide show
  1. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/PKG-INFO +1 -1
  2. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/pyproject.toml +1 -1
  3. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/actions.py +3 -0
  4. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/backup_service.py +134 -0
  5. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/ingest_service.py +194 -0
  6. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/main.py +51 -0
  7. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/PKG-INFO +1 -1
  8. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/README.md +0 -0
  9. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/setup.cfg +0 -0
  10. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/__init__.py +0 -0
  11. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/config.py +0 -0
  12. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/core/__init__.py +0 -0
  13. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/core/date_utils.py +0 -0
  14. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/core/fs_ops.py +0 -0
  15. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/core/media_ops.py +0 -0
  16. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/core/patterns.py +0 -0
  17. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/delivery_service.py +0 -0
  18. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow/utils_date.py +0 -0
  19. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/SOURCES.txt +0 -0
  20. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/dependency_links.txt +0 -0
  21. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/entry_points.txt +0 -0
  22. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/requires.txt +0 -0
  23. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/top_level.txt +0 -0
  24. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_braw_and_prores_extensions.py +0 -0
  25. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_backup_and_verify.py +0 -0
  26. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_ingest_and_pull_filters.py +0 -0
  27. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_ingest_report.py +0 -0
  28. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_list_backups.py +0 -0
  29. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_restore_folder.py +0 -0
  30. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_cli_verify_backup_mirror.py +0 -0
  31. {vflow_cli-0.1.4 → vflow_cli-0.1.5}/tests/test_patterns_and_duplicates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vflow-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: The v-flow CLI and Claude skills bundle for automating media backup and processing workflows for videographers.
5
5
  Author-email: Kaung <kaungzinye@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vflow-cli"
3
- version = "0.1.4"
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'"),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vflow-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: The v-flow CLI and Claude skills bundle for automating media backup and processing workflows for videographers.
5
5
  Author-email: Kaung <kaungzinye@gmail.com>
6
6
  Requires-Python: >=3.8
File without changes
File without changes
File without changes