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/__init__.py +1 -0
- vflow/actions.py +39 -0
- vflow/backup_service.py +684 -0
- vflow/config.py +44 -0
- vflow/core/__init__.py +6 -0
- vflow/core/date_utils.py +114 -0
- vflow/core/fs_ops.py +64 -0
- vflow/core/media_ops.py +110 -0
- vflow/core/patterns.py +98 -0
- vflow/delivery_service.py +301 -0
- vflow/ingest_service.py +932 -0
- vflow/main.py +571 -0
- vflow_cli-0.1.1.dist-info/METADATA +543 -0
- vflow_cli-0.1.1.dist-info/RECORD +17 -0
- vflow_cli-0.1.1.dist-info/WHEEL +5 -0
- vflow_cli-0.1.1.dist-info/entry_points.txt +2 -0
- vflow_cli-0.1.1.dist-info/top_level.txt +1 -0
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
vflow/core/date_utils.py
ADDED
|
@@ -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
|
+
|
vflow/core/media_ops.py
ADDED
|
@@ -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
|
+
|