vflow-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vflow/config.py ADDED
@@ -0,0 +1,44 @@
1
+ import yaml
2
+ from pathlib import Path
3
+ import typer
4
+
5
+ CONFIG_PATH = Path.home() / ".vflow_config.yml"
6
+
7
+ def load_config():
8
+ """Loads the configuration from the user's home directory."""
9
+ if not CONFIG_PATH.exists():
10
+ typer.echo(f"Configuration file not found at: {CONFIG_PATH}")
11
+ typer.echo("Please create this file with your storage locations.")
12
+ raise typer.Exit(code=1)
13
+
14
+ with open(CONFIG_PATH, "r") as f:
15
+ try:
16
+ config = yaml.safe_load(f)
17
+ except yaml.YAMLError as e:
18
+ typer.echo(f"Error parsing configuration file: {e}")
19
+ raise typer.Exit(code=1)
20
+
21
+ # Basic validation
22
+ if "locations" not in config or not isinstance(config["locations"], dict):
23
+ typer.echo("Configuration file must contain a 'locations' dictionary.")
24
+ raise typer.Exit(code=1)
25
+
26
+ return config
27
+
28
+ def get_location(config: dict, name: str) -> Path:
29
+ """Gets a specific location from the config and ensures it exists."""
30
+ path_str = config["locations"].get(name)
31
+ if not path_str:
32
+ typer.echo(f"Location '{name}' not defined in config file.")
33
+ raise typer.Exit(code=1)
34
+
35
+ path = Path(path_str)
36
+ if not path.exists() or not path.is_dir():
37
+ typer.echo(f"The directory for location '{name}' does not exist: {path}")
38
+ raise typer.Exit(code=1)
39
+
40
+ return path
41
+
42
+ def get_setting(config: dict, key: str, default: any = None) -> any:
43
+ """Gets a setting from the config, returning default if not found."""
44
+ return config.get("settings", {}).get(key, default)
vflow/core/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Core internal helpers for v-flow.
3
+
4
+ This module exposes small, reusable utilities that are shared across the
5
+ different action and service modules.
6
+ """
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime
4
+ from typing import Optional, Tuple
5
+ import re
6
+
7
+
8
+ def parse_shoot_date_range(shoot_name: str) -> Optional[Tuple[date, date]]:
9
+ """
10
+ Parse a shoot name to extract date range.
11
+ Returns (start_date, end_date) or None if no date found.
12
+
13
+ Supports formats:
14
+ - YYYY-MM-DD_ShootName (single date)
15
+ - YYYY-MM-DD_to_YYYY-MM-DD_ShootName (date range)
16
+ """
17
+ # Pattern for date range: YYYY-MM-DD_to_YYYY-MM-DD_ShootName
18
+ range_pattern = r"^(\d{4}-\d{2}-\d{2})_to_(\d{4}-\d{2}-\d{2})_(.+)$"
19
+ match = re.match(range_pattern, shoot_name)
20
+ if match:
21
+ try:
22
+ start = datetime.strptime(match.group(1), "%Y-%m-%d").date()
23
+ end = datetime.strptime(match.group(2), "%Y-%m-%d").date()
24
+ return (start, end)
25
+ except ValueError:
26
+ pass
27
+
28
+ # Pattern for single date: YYYY-MM-DD_ShootName
29
+ single_pattern = r"^(\d{4}-\d{2}-\d{2})_(.+)$"
30
+ match = re.match(single_pattern, shoot_name)
31
+ if match:
32
+ try:
33
+ shoot_date = datetime.strptime(match.group(1), "%Y-%m-%d").date()
34
+ return (shoot_date, shoot_date)
35
+ except ValueError:
36
+ pass
37
+
38
+ return None
39
+
40
+
41
+ def format_shoot_name(
42
+ start_date: date, end_date: date, name_suffix: str = "Ingest"
43
+ ) -> str:
44
+ """
45
+ Format a shoot name from a date range.
46
+ If start and end are the same, use single date format.
47
+ """
48
+ if start_date == end_date:
49
+ return f"{start_date.strftime('%Y-%m-%d')}_{name_suffix}"
50
+ else:
51
+ return (
52
+ f"{start_date.strftime('%Y-%m-%d')}_to_"
53
+ f"{end_date.strftime('%Y-%m-%d')}_{name_suffix}"
54
+ )
55
+
56
+
57
+ def date_in_range(check_date: date, start_date: date, end_date: date) -> bool:
58
+ """Check if a date falls within a date range (inclusive)."""
59
+ return start_date <= check_date <= end_date
60
+
61
+
62
+ def cluster_files_by_date(
63
+ files_with_dates: list[Tuple[object, datetime]], gap_hours: int
64
+ ) -> list[list[object]]:
65
+ """
66
+ Group files into clusters based on a time gap threshold.
67
+ files_with_dates: List of tuples (file_path, file_date)
68
+ gap_hours: Minimum gap in hours to trigger a split
69
+
70
+ Returns a list of lists, where each inner list contains file paths for one cluster.
71
+ """
72
+ if not files_with_dates:
73
+ return []
74
+
75
+ # Sort by date
76
+ sorted_files = sorted(files_with_dates, key=lambda x: x[1])
77
+
78
+ clusters: list[list[object]] = []
79
+ current_cluster: list[object] = []
80
+
81
+ if not sorted_files:
82
+ return []
83
+
84
+ # Start first cluster
85
+ current_cluster.append(sorted_files[0][0])
86
+ last_date = sorted_files[0][1]
87
+
88
+ for i in range(1, len(sorted_files)):
89
+ file_path, file_date = sorted_files[i]
90
+
91
+ # Calculate difference in hours
92
+ # Ensure we are comparing datetimes
93
+ if isinstance(file_date, date) and not isinstance(file_date, datetime):
94
+ # Fallback if we only have dates (assume midnight)
95
+ file_date = datetime.combine(file_date, datetime.min.time())
96
+ if isinstance(last_date, date) and not isinstance(last_date, datetime):
97
+ last_date = datetime.combine(last_date, datetime.min.time())
98
+
99
+ diff = file_date - last_date
100
+ diff_hours = diff.total_seconds() / 3600
101
+
102
+ if diff_hours >= gap_hours:
103
+ # Gap exceeded, start new cluster
104
+ clusters.append(current_cluster)
105
+ current_cluster = []
106
+
107
+ current_cluster.append(file_path)
108
+ last_date = file_date
109
+
110
+ if current_cluster:
111
+ clusters.append(current_cluster)
112
+
113
+ return clusters
114
+
vflow/core/fs_ops.py ADDED
@@ -0,0 +1,64 @@
1
+ from pathlib import Path
2
+ from typing import Set, Tuple
3
+
4
+ import shutil
5
+ import typer
6
+
7
+
8
+ def copy_and_verify(source: Path, dest: Path) -> bool:
9
+ """Copies a file and verifies its existence."""
10
+ try:
11
+ shutil.copy2(source, dest)
12
+ if not (dest / source.name).exists():
13
+ typer.echo(f" [ERROR] Verification failed for {source.name} at {dest}", err=True)
14
+ return False
15
+ except Exception as e:
16
+ typer.echo(f" [ERROR] Could not copy {source.name} to {dest}: {e}", err=True)
17
+ return False
18
+ return True
19
+
20
+
21
+ def _is_duplicate(file_path: Path, dest_dir: Path) -> bool:
22
+ """
23
+ Check if a file is a duplicate at the destination (by name and size).
24
+ """
25
+ dest_file = dest_dir / file_path.name
26
+ if not dest_file.exists():
27
+ return False
28
+
29
+ try:
30
+ source_size = file_path.stat().st_size
31
+ dest_size = dest_file.stat().st_size
32
+ return source_size == dest_size
33
+ except Exception:
34
+ return False
35
+
36
+
37
+ def _build_destination_index(root: Path) -> Set[Tuple[str, int]]:
38
+ """
39
+ Build a set of (filename, size) for all video files under root.
40
+ Used to skip files already ingested anywhere in laptop or archive (cross-shoot).
41
+ """
42
+ video_extensions = {".mp4", ".mov", ".mxf", ".mts", ".avi", ".m4v", ".braw", ".r3d", ".crm"}
43
+ index: Set[Tuple[str, int]] = set()
44
+ if not root.exists():
45
+ return index
46
+ for f in root.rglob("*"):
47
+ if f.is_file() and f.suffix.lower() in video_extensions:
48
+ try:
49
+ index.add((f.name, f.stat().st_size))
50
+ except (OSError, FileNotFoundError):
51
+ pass
52
+ return index
53
+
54
+
55
+ def _format_bytes(num: int) -> str:
56
+ """
57
+ Simple human-readable byte formatter.
58
+ """
59
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
60
+ if num < 1024.0:
61
+ return f"{num:.1f} {unit}"
62
+ num /= 1024.0
63
+ return f"{num:.1f} PB"
64
+
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+
10
+ def tag_media_file(source_file: Path, tags_str: str) -> Path:
11
+ """
12
+ Embed metadata tags into a media file via ffmpeg and apply macOS Finder tags.
13
+ Returns the path to the new tagged copy (caller is responsible for cleanup).
14
+ """
15
+ tags_list = [tag.strip() for tag in tags_str.split(",")]
16
+
17
+ tagged_file_path = source_file.with_name(
18
+ f"{source_file.stem}_tagged{source_file.suffix}"
19
+ )
20
+
21
+ typer.echo("Embedding universal metadata with ffmpeg...")
22
+ try:
23
+ ffmpeg_cmd = [
24
+ "ffmpeg",
25
+ "-i",
26
+ str(source_file),
27
+ "-metadata",
28
+ f"comment={tags_str}",
29
+ "-metadata",
30
+ f"keywords={tags_str}",
31
+ "-codec",
32
+ "copy",
33
+ str(tagged_file_path),
34
+ ]
35
+ subprocess.run(ffmpeg_cmd, check=True, capture_output=True, text=True)
36
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
37
+ typer.echo(f"Error with ffmpeg: {e}", err=True)
38
+ typer.echo(
39
+ "Please ensure ffmpeg is installed and in your PATH.", err=True
40
+ )
41
+ raise typer.Exit(code=1)
42
+
43
+ typer.echo("Applying macOS Finder tags...")
44
+ try:
45
+ tag_plist = "".join(f"<string>{tag}</string>" for tag in tags_list)
46
+ bplist_cmd = (
47
+ "xattr -w com.apple.metadata:_kMDItemUserTags "
48
+ '\'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
49
+ '"http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0">'
50
+ f"<array>{tag_plist}</array></plist>' "
51
+ f'"{str(tagged_file_path)}"'
52
+ )
53
+ subprocess.run(
54
+ bplist_cmd, shell=True, check=True, capture_output=True
55
+ )
56
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
57
+ typer.echo(f"Could not apply macOS tags: {e}", err=True)
58
+
59
+ return tagged_file_path
60
+
61
+
62
+ def copy_metadata_between_files(source_file: Path, target_file: Path) -> bool:
63
+ """
64
+ Copy metadata from a source file to a target file using ffmpeg.
65
+ Preserves video/audio streams from target, adds metadata from source.
66
+ Returns True if successful, False otherwise.
67
+ """
68
+ if not source_file.exists() or not target_file.exists():
69
+ return False
70
+
71
+ temp_output = target_file.with_name(
72
+ f"{target_file.stem}_temp_meta{target_file.suffix}"
73
+ )
74
+
75
+ try:
76
+ ffmpeg_cmd = [
77
+ "ffmpeg",
78
+ "-i",
79
+ str(target_file),
80
+ "-i",
81
+ str(source_file),
82
+ "-map",
83
+ "0:v",
84
+ "-map",
85
+ "0:a",
86
+ "-map_metadata",
87
+ "1",
88
+ "-c:v",
89
+ "copy",
90
+ "-c:a",
91
+ "copy",
92
+ "-y",
93
+ str(temp_output),
94
+ ]
95
+ subprocess.run(ffmpeg_cmd, check=True, capture_output=True, text=True)
96
+
97
+ shutil.move(str(temp_output), str(target_file))
98
+ return True
99
+
100
+ except FileNotFoundError:
101
+ typer.echo(
102
+ "\nError: ffmpeg not found. Please install it and ensure it's in your PATH.",
103
+ err=True,
104
+ )
105
+ return False
106
+ except subprocess.CalledProcessError as e:
107
+ typer.echo(f"\nWarning: Could not copy metadata: {e.stderr}", err=True)
108
+ if temp_output.exists():
109
+ temp_output.unlink()
110
+ return False
vflow/core/patterns.py ADDED
@@ -0,0 +1,98 @@
1
+ import re
2
+ from typing import Optional
3
+ from pathlib import Path
4
+
5
+
6
+ def _extract_number_from_filename(filename: str) -> Optional[int]:
7
+ """
8
+ Extract the first numeric sequence from a filename.
9
+ Returns the number as an integer, or None if no number found.
10
+ Handles zero-padding by extracting the numeric value.
11
+ """
12
+ match = re.search(r"(\d+)", filename)
13
+ if match:
14
+ return int(match.group(1))
15
+ return None
16
+
17
+
18
+ def _parse_range_pattern(pattern: str) -> tuple[Optional[str], Optional[int], Optional[int]]:
19
+ """
20
+ Parse a range pattern like "C3317-C3351" or "3317-3351" or "C3317-3351".
21
+ Returns (prefix, start_num, end_num) or (None, None, None) if not a range.
22
+ """
23
+ # Pattern to match ranges like "C3317-C3351" or "3317-3351" or "C3317-3351"
24
+ # The pattern can have a prefix before the first number, and optionally before the second
25
+ pattern_upper = pattern.upper()
26
+
27
+ # Try pattern with prefix on both sides: "C3317-C3351"
28
+ range_match = re.match(r"^([A-Za-z]*?)(\d+)-([A-Za-z]*?)(\d+)$", pattern_upper)
29
+ if range_match:
30
+ prefix1 = range_match.group(1) if range_match.group(1) else None
31
+ prefix2 = range_match.group(3) if range_match.group(3) else None
32
+
33
+ # Use the prefix from the first number, but require both to match (or both be None)
34
+ if (prefix1 is None and prefix2 is None) or (prefix1 and prefix2 and prefix1 == prefix2):
35
+ prefix = prefix1
36
+ start_num = int(range_match.group(2))
37
+ end_num = int(range_match.group(4))
38
+
39
+ if start_num <= end_num:
40
+ return (prefix, start_num, end_num)
41
+
42
+ # Try pattern with no prefix: "3317-3351" or with prefix only on first: "C3317-3351"
43
+ # This regex allows digits after the dash, and will capture prefix from first number only
44
+ range_match = re.match(r"^([A-Za-z]*?)(\d+)-(\d+)$", pattern_upper)
45
+ if range_match:
46
+ prefix = range_match.group(1) if range_match.group(1) else None
47
+ start_num = int(range_match.group(2))
48
+ end_num = int(range_match.group(3))
49
+
50
+ if start_num <= end_num:
51
+ return (prefix, start_num, end_num)
52
+
53
+ return (None, None, None) # Not a range
54
+
55
+
56
+ def _matches_pattern(pattern: str, filename: str) -> bool:
57
+ """
58
+ Check if a filename matches a pattern, handling both regular patterns and ranges.
59
+ Uses numeric comparison to handle zero-padding.
60
+ """
61
+ filename_lower = filename.lower()
62
+ pattern_lower = pattern.lower()
63
+
64
+ # First, check if pattern is a range
65
+ prefix, start_num, end_num = _parse_range_pattern(pattern)
66
+ if start_num is not None and end_num is not None:
67
+ # It's a range - extract number from filename and check if in range
68
+ file_num = _extract_number_from_filename(filename)
69
+ if file_num is None:
70
+ return False
71
+
72
+ # If prefix specified, check that filename contains the prefix
73
+ if prefix:
74
+ if prefix.lower() not in filename_lower:
75
+ return False
76
+
77
+ # Check if number is in range
78
+ return start_num <= file_num <= end_num
79
+
80
+ # Not a range - try numeric matching first (for better zero-padding handling)
81
+ pattern_num = _extract_number_from_filename(pattern)
82
+ file_num = _extract_number_from_filename(filename)
83
+
84
+ if pattern_num is not None and file_num is not None:
85
+ # Both have numbers - compare numerically and check prefix
86
+ if pattern_num == file_num:
87
+ # Numbers match - check if prefixes match (if pattern has a prefix)
88
+ pattern_letters = re.sub(r"\d+", "", pattern_lower)
89
+ if pattern_letters:
90
+ # Pattern has letters - check if filename contains them
91
+ return pattern_letters in filename_lower
92
+ else:
93
+ # Just a number pattern - match if filename contains this number
94
+ return True
95
+
96
+ # Fallback to substring matching for non-numeric patterns
97
+ return pattern_lower in filename_lower
98
+