vflow-cli 0.1.3__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.3 → vflow_cli-0.1.5}/PKG-INFO +1 -1
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/pyproject.toml +1 -1
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/actions.py +3 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/backup_service.py +134 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/config.py +4 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/ingest_service.py +194 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/main.py +101 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/PKG-INFO +1 -1
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/README.md +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/setup.cfg +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/__init__.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/core/__init__.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/core/date_utils.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/core/fs_ops.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/core/media_ops.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/core/patterns.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/delivery_service.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow/utils_date.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/SOURCES.txt +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/dependency_links.txt +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/entry_points.txt +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/requires.txt +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/src/vflow_cli.egg-info/top_level.txt +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/tests/test_braw_and_prores_extensions.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/tests/test_cli_backup_and_verify.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/tests/test_cli_ingest_and_pull_filters.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/tests/test_cli_ingest_report.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/tests/test_cli_list_backups.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/tests/test_cli_restore_folder.py +0 -0
- {vflow_cli-0.1.3 → vflow_cli-0.1.5}/tests/test_cli_verify_backup_mirror.py +0 -0
- {vflow_cli-0.1.3 → 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]]]:
|
|
@@ -42,3 +42,7 @@ def get_location(config: dict, name: str) -> Path:
|
|
|
42
42
|
def get_setting(config: dict, key: str, default: any = None) -> any:
|
|
43
43
|
"""Gets a setting from the config, returning default if not found."""
|
|
44
44
|
return config.get("settings", {}).get(key, default)
|
|
45
|
+
|
|
46
|
+
def save_config(config: dict) -> None:
|
|
47
|
+
with open(CONFIG_PATH, "w") as f:
|
|
48
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
@@ -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'"),
|
|
@@ -394,6 +445,56 @@ def copy_meta(
|
|
|
394
445
|
|
|
395
446
|
|
|
396
447
|
|
|
448
|
+
LOCATION_KEYS = {"laptop", "work_ssd", "archive_hdd"}
|
|
449
|
+
|
|
450
|
+
@app.command()
|
|
451
|
+
def locations():
|
|
452
|
+
"""Show configured v-flow paths and whether they are currently mounted."""
|
|
453
|
+
app_config = config.load_config()
|
|
454
|
+
locs = app_config.get("locations", {})
|
|
455
|
+
settings = app_config.get("settings", {})
|
|
456
|
+
typer.echo("Locations:")
|
|
457
|
+
for key, path in locs.items():
|
|
458
|
+
mounted = "✓" if Path(path).exists() else "✗ not mounted"
|
|
459
|
+
typer.echo(f" {key}: {path} [{mounted}]")
|
|
460
|
+
if settings:
|
|
461
|
+
typer.echo("Settings:")
|
|
462
|
+
for key, val in settings.items():
|
|
463
|
+
typer.echo(f" {key}: {val}")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@app.command("set")
|
|
467
|
+
def set_config(
|
|
468
|
+
key: str = typer.Argument(help="Config key to update. Location keys: laptop, work_ssd, archive_hdd. Settings: settings.<key> (e.g. settings.default_split_gap)."),
|
|
469
|
+
value: str = typer.Argument(help="New value for the key."),
|
|
470
|
+
):
|
|
471
|
+
"""Update a single config value without re-running setup.
|
|
472
|
+
|
|
473
|
+
Examples:
|
|
474
|
+
v-flow set archive_hdd "/Volumes/Kaung HDD/MediaArchive"
|
|
475
|
+
v-flow set laptop "/Users/me/Desktop/Ingest"
|
|
476
|
+
v-flow set settings.default_split_gap 24
|
|
477
|
+
"""
|
|
478
|
+
app_config = config.load_config()
|
|
479
|
+
|
|
480
|
+
if key in LOCATION_KEYS:
|
|
481
|
+
app_config.setdefault("locations", {})[key] = value
|
|
482
|
+
typer.echo(f"Set locations.{key} = {value}")
|
|
483
|
+
elif key.startswith("settings."):
|
|
484
|
+
setting_key = key[len("settings."):]
|
|
485
|
+
app_config.setdefault("settings", {})[setting_key] = value
|
|
486
|
+
typer.echo(f"Set settings.{setting_key} = {value}")
|
|
487
|
+
else:
|
|
488
|
+
typer.echo(
|
|
489
|
+
f"Unknown key '{key}'. Use one of: {', '.join(sorted(LOCATION_KEYS))}, or settings.<key>.",
|
|
490
|
+
err=True,
|
|
491
|
+
)
|
|
492
|
+
raise typer.Exit(code=1)
|
|
493
|
+
|
|
494
|
+
config.save_config(app_config)
|
|
495
|
+
typer.echo(f"Config saved to {config.CONFIG_PATH}")
|
|
496
|
+
|
|
497
|
+
|
|
397
498
|
@app.command()
|
|
398
499
|
def make_config():
|
|
399
500
|
"""
|
|
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
|