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/backup_service.py
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from .core.fs_ops import copy_and_verify, _format_bytes
|
|
9
|
+
from .core.patterns import _matches_pattern
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def consolidate_files(
|
|
13
|
+
source_dir: str,
|
|
14
|
+
output_folder_name: Optional[str],
|
|
15
|
+
archive_path: Path,
|
|
16
|
+
destination_path: Optional[str] = None,
|
|
17
|
+
file_filter: Optional[list[str]] = None,
|
|
18
|
+
tags: Optional[str] = None,
|
|
19
|
+
preserve_structure: bool = True,
|
|
20
|
+
dry_run: bool = False,
|
|
21
|
+
delete_source: bool = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Finds unique files from a source directory and copies them to the archive.
|
|
25
|
+
"""
|
|
26
|
+
source_path = Path(source_dir)
|
|
27
|
+
|
|
28
|
+
if not source_path.is_dir():
|
|
29
|
+
typer.echo(f"Source is not a valid directory: {source_path}", err=True)
|
|
30
|
+
raise typer.Exit(code=1)
|
|
31
|
+
|
|
32
|
+
if destination_path:
|
|
33
|
+
output_path = archive_path / destination_path
|
|
34
|
+
elif output_folder_name:
|
|
35
|
+
output_path = archive_path / output_folder_name
|
|
36
|
+
else:
|
|
37
|
+
typer.echo(
|
|
38
|
+
"Either --output-folder or --destination must be provided.", err=True
|
|
39
|
+
)
|
|
40
|
+
raise typer.Exit(code=1)
|
|
41
|
+
|
|
42
|
+
if not dry_run:
|
|
43
|
+
try:
|
|
44
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
typer.echo(f"Could not create output directory: {e}", err=True)
|
|
47
|
+
raise typer.Exit(code=1)
|
|
48
|
+
|
|
49
|
+
typer.echo("Building index of existing archive files (this may take a moment)...")
|
|
50
|
+
archive_index: set[tuple[str, int]] = set()
|
|
51
|
+
all_archive_files = list(archive_path.rglob("*.*"))
|
|
52
|
+
with typer.progressbar(all_archive_files, label="Indexing archive") as progress:
|
|
53
|
+
for file in progress:
|
|
54
|
+
if file.is_file():
|
|
55
|
+
try:
|
|
56
|
+
archive_index.add((file.name, file.stat().st_size))
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
video_extensions = {
|
|
61
|
+
".mp4",
|
|
62
|
+
".mov",
|
|
63
|
+
".mxf",
|
|
64
|
+
".mts",
|
|
65
|
+
".avi",
|
|
66
|
+
".m4v",
|
|
67
|
+
".braw",
|
|
68
|
+
".r3d",
|
|
69
|
+
".crm",
|
|
70
|
+
}
|
|
71
|
+
typer.echo("Scanning source directory...")
|
|
72
|
+
all_source_files = list(source_path.rglob("*.*"))
|
|
73
|
+
source_files = [
|
|
74
|
+
f
|
|
75
|
+
for f in all_source_files
|
|
76
|
+
if f.is_file() and f.suffix.lower() in video_extensions
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
if file_filter:
|
|
80
|
+
filtered_files: list[Path] = []
|
|
81
|
+
for pattern in file_filter:
|
|
82
|
+
pattern_path = source_path / pattern
|
|
83
|
+
if pattern_path.exists():
|
|
84
|
+
if pattern_path.is_file() and pattern_path.suffix.lower() in video_extensions:
|
|
85
|
+
filtered_files.append(pattern_path)
|
|
86
|
+
elif pattern_path.is_dir():
|
|
87
|
+
for file_path in pattern_path.rglob("*"):
|
|
88
|
+
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
|
|
89
|
+
filtered_files.append(file_path)
|
|
90
|
+
else:
|
|
91
|
+
for f in source_files:
|
|
92
|
+
rel_path_str = str(f.relative_to(source_path))
|
|
93
|
+
if (
|
|
94
|
+
_matches_pattern(pattern, f.name)
|
|
95
|
+
or _matches_pattern(pattern, rel_path_str)
|
|
96
|
+
or any(
|
|
97
|
+
_matches_pattern(pattern, part)
|
|
98
|
+
for part in f.relative_to(source_path).parts
|
|
99
|
+
)
|
|
100
|
+
):
|
|
101
|
+
filtered_files.append(f)
|
|
102
|
+
source_files = list(dict.fromkeys(filtered_files))
|
|
103
|
+
|
|
104
|
+
if not source_files:
|
|
105
|
+
typer.echo(
|
|
106
|
+
f"⚠ No files found matching filter: {', '.join(file_filter)}", err=True
|
|
107
|
+
)
|
|
108
|
+
typer.echo(
|
|
109
|
+
f" Searched {len(all_source_files)} file(s) in: {source_path}"
|
|
110
|
+
)
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
typer.echo(
|
|
114
|
+
f"Found {len(source_files)} file(s) matching filter (out of {len([f for f in all_source_files if f.is_file()])} total)."
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
typer.echo(f"Found {len(source_files)} video file(s) to process.")
|
|
118
|
+
|
|
119
|
+
typer.echo(
|
|
120
|
+
f"{'Dry-run: would copy unique files to' if dry_run else 'Copying unique files to'}: {output_path}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
copied_count = 0
|
|
124
|
+
skipped_count = 0
|
|
125
|
+
error_count = 0
|
|
126
|
+
copied_sources: list[Path] = []
|
|
127
|
+
|
|
128
|
+
if dry_run:
|
|
129
|
+
with typer.progressbar(source_files, label="Analyzing for backup") as progress:
|
|
130
|
+
for file in progress:
|
|
131
|
+
try:
|
|
132
|
+
if preserve_structure:
|
|
133
|
+
rel_path = file.relative_to(source_path)
|
|
134
|
+
dest_file = output_path / rel_path
|
|
135
|
+
else:
|
|
136
|
+
dest_file = output_path / file.name
|
|
137
|
+
|
|
138
|
+
file_id = (file.name, file.stat().st_size)
|
|
139
|
+
|
|
140
|
+
if file_id in archive_index:
|
|
141
|
+
skipped_count += 1
|
|
142
|
+
typer.echo(f"SKIP (already in archive): {file}")
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
if dest_file.exists():
|
|
146
|
+
try:
|
|
147
|
+
source_size = file.stat().st_size
|
|
148
|
+
dest_size = dest_file.stat().st_size
|
|
149
|
+
if source_size == dest_size:
|
|
150
|
+
skipped_count += 1
|
|
151
|
+
typer.echo(
|
|
152
|
+
f"SKIP (already at destination): {file}"
|
|
153
|
+
)
|
|
154
|
+
continue
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
copied_count += 1
|
|
159
|
+
typer.echo(f"WOULD COPY: {file} -> {dest_file}")
|
|
160
|
+
if delete_source:
|
|
161
|
+
typer.echo(
|
|
162
|
+
f"WOULD DELETE AFTER COPY (after manual confirmation): {file}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
except FileNotFoundError:
|
|
166
|
+
continue
|
|
167
|
+
except Exception as e:
|
|
168
|
+
typer.echo(
|
|
169
|
+
f"\n[ERROR] Could not analyze {file.name}: {e}", err=True
|
|
170
|
+
)
|
|
171
|
+
error_count += 1
|
|
172
|
+
|
|
173
|
+
typer.echo("\nBackup dry-run complete.")
|
|
174
|
+
typer.echo(f"{copied_count} file(s) would be copied.")
|
|
175
|
+
typer.echo(f"{skipped_count} file(s) would be skipped as duplicates.")
|
|
176
|
+
if error_count > 0:
|
|
177
|
+
typer.echo(f"Errors during analysis: {error_count}", err=True)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
copied_log_path = output_path / "copied_files.txt"
|
|
181
|
+
skipped_log_path = output_path / "skipped_duplicates.txt"
|
|
182
|
+
|
|
183
|
+
with copied_log_path.open("w") as copied_log, skipped_log_path.open(
|
|
184
|
+
"w"
|
|
185
|
+
) as skipped_log:
|
|
186
|
+
with typer.progressbar(source_files, label="Consolidating") as progress:
|
|
187
|
+
for file in progress:
|
|
188
|
+
try:
|
|
189
|
+
if preserve_structure:
|
|
190
|
+
rel_path = file.relative_to(source_path)
|
|
191
|
+
dest_file = output_path / rel_path
|
|
192
|
+
dest_dir = dest_file.parent
|
|
193
|
+
else:
|
|
194
|
+
dest_file = output_path / file.name
|
|
195
|
+
dest_dir = output_path
|
|
196
|
+
|
|
197
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
|
|
199
|
+
file_id = (file.name, file.stat().st_size)
|
|
200
|
+
if file_id in archive_index:
|
|
201
|
+
skipped_log.write(f"{file}\n")
|
|
202
|
+
skipped_count += 1
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if dest_file.exists():
|
|
206
|
+
try:
|
|
207
|
+
source_size = file.stat().st_size
|
|
208
|
+
dest_size = dest_file.stat().st_size
|
|
209
|
+
if source_size == dest_size:
|
|
210
|
+
skipped_log.write(f"{file}\n")
|
|
211
|
+
skipped_count += 1
|
|
212
|
+
continue
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
if copy_and_verify(file, dest_dir):
|
|
217
|
+
copied_log.write(f"{file}\n")
|
|
218
|
+
archive_index.add(file_id)
|
|
219
|
+
copied_count += 1
|
|
220
|
+
|
|
221
|
+
if tags:
|
|
222
|
+
from .core.media_ops import tag_media_file
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
tagged_file = tag_media_file(dest_file, tags)
|
|
226
|
+
from shutil import move
|
|
227
|
+
|
|
228
|
+
move(str(tagged_file), str(dest_file))
|
|
229
|
+
except Exception as e:
|
|
230
|
+
typer.echo(
|
|
231
|
+
f"\n⚠ Warning: Could not tag {dest_file.name}: {e}",
|
|
232
|
+
err=True,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if delete_source:
|
|
236
|
+
copied_sources.append(file)
|
|
237
|
+
else:
|
|
238
|
+
error_count += 1
|
|
239
|
+
|
|
240
|
+
except FileNotFoundError:
|
|
241
|
+
continue
|
|
242
|
+
except Exception as e:
|
|
243
|
+
typer.echo(
|
|
244
|
+
f"\n[ERROR] Could not process {file.name}: {e}", err=True
|
|
245
|
+
)
|
|
246
|
+
error_count += 1
|
|
247
|
+
|
|
248
|
+
typer.echo("\nConsolidation complete.")
|
|
249
|
+
typer.echo(f"{copied_count} unique files copied.")
|
|
250
|
+
typer.echo(f"{skipped_count} duplicate files skipped.")
|
|
251
|
+
if error_count > 0:
|
|
252
|
+
typer.echo(f"Errors: {error_count}", err=True)
|
|
253
|
+
typer.echo(f"See log files in {output_path} for details.")
|
|
254
|
+
|
|
255
|
+
if delete_source and copied_sources:
|
|
256
|
+
typer.echo("\nBackup step finished.")
|
|
257
|
+
typer.echo(
|
|
258
|
+
f"{len(copied_sources)} source file(s) are eligible for deletion (only files that were actually copied)."
|
|
259
|
+
)
|
|
260
|
+
confirm = typer.confirm(
|
|
261
|
+
"Do you want to delete these source files from the backup source folder now?"
|
|
262
|
+
)
|
|
263
|
+
if confirm:
|
|
264
|
+
deleted = 0
|
|
265
|
+
delete_errors = 0
|
|
266
|
+
for src in copied_sources:
|
|
267
|
+
try:
|
|
268
|
+
if src.exists():
|
|
269
|
+
src.unlink()
|
|
270
|
+
deleted += 1
|
|
271
|
+
except Exception as e:
|
|
272
|
+
delete_errors += 1
|
|
273
|
+
typer.echo(
|
|
274
|
+
f"⚠ Warning: Could not delete source file {src}: {e}",
|
|
275
|
+
err=True,
|
|
276
|
+
)
|
|
277
|
+
typer.echo(f"\nSource cleanup complete. Deleted {deleted} file(s).")
|
|
278
|
+
if delete_errors:
|
|
279
|
+
typer.echo(
|
|
280
|
+
f"{delete_errors} file(s) could not be deleted. See warnings above.",
|
|
281
|
+
err=True,
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
typer.echo(
|
|
285
|
+
"\nNo source files were deleted. You can safely inspect the archive and rerun backup with --delete-source later if desired."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def verify_backup(
|
|
290
|
+
source_dir: str,
|
|
291
|
+
dest_dir: str,
|
|
292
|
+
allow_delete: bool = False,
|
|
293
|
+
archive_wide: bool = False,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Verify that all files in source_dir exist in dest_dir with the same relative path and size.
|
|
297
|
+
"""
|
|
298
|
+
source_path = Path(source_dir)
|
|
299
|
+
dest_path = Path(dest_dir)
|
|
300
|
+
|
|
301
|
+
if not source_path.is_dir():
|
|
302
|
+
typer.echo(f"Source is not a valid directory: {source_path}", err=True)
|
|
303
|
+
raise typer.Exit(code=1)
|
|
304
|
+
if not dest_path.is_dir():
|
|
305
|
+
typer.echo(f"Destination is not a valid directory: {dest_path}", err=True)
|
|
306
|
+
raise typer.Exit(code=1)
|
|
307
|
+
|
|
308
|
+
scope_desc = (
|
|
309
|
+
"archive-wide (by name+size anywhere under destination)"
|
|
310
|
+
if archive_wide
|
|
311
|
+
else "by relative path"
|
|
312
|
+
)
|
|
313
|
+
typer.echo(
|
|
314
|
+
"Verifying backup from:\n"
|
|
315
|
+
f" Source: {source_path}\n"
|
|
316
|
+
f" Destination: {dest_path}\n"
|
|
317
|
+
f" Scope: {scope_desc}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if archive_wide:
|
|
321
|
+
dest_index_name_size: set[tuple[str, int]] = set()
|
|
322
|
+
for f in dest_path.rglob("*"):
|
|
323
|
+
if f.is_file():
|
|
324
|
+
try:
|
|
325
|
+
dest_index_name_size.add((f.name, f.stat().st_size))
|
|
326
|
+
except (OSError, FileNotFoundError):
|
|
327
|
+
continue
|
|
328
|
+
else:
|
|
329
|
+
dest_index: dict[Path, int] = {}
|
|
330
|
+
for f in dest_path.rglob("*"):
|
|
331
|
+
if f.is_file():
|
|
332
|
+
try:
|
|
333
|
+
rel = f.relative_to(dest_path)
|
|
334
|
+
dest_index[rel] = f.stat().st_size
|
|
335
|
+
except (OSError, FileNotFoundError):
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
missing: list[Path] = []
|
|
339
|
+
size_mismatch: list[tuple[Path, int, int]] = []
|
|
340
|
+
checked = 0
|
|
341
|
+
|
|
342
|
+
for f in source_path.rglob("*"):
|
|
343
|
+
if not f.is_file():
|
|
344
|
+
continue
|
|
345
|
+
try:
|
|
346
|
+
rel = f.relative_to(source_path)
|
|
347
|
+
size = f.stat().st_size
|
|
348
|
+
except (OSError, FileNotFoundError):
|
|
349
|
+
continue
|
|
350
|
+
checked += 1
|
|
351
|
+
|
|
352
|
+
if archive_wide:
|
|
353
|
+
if (f.name, size) not in dest_index_name_size:
|
|
354
|
+
missing.append(rel)
|
|
355
|
+
else:
|
|
356
|
+
dest_size = dest_index.get(rel)
|
|
357
|
+
if dest_size is None:
|
|
358
|
+
missing.append(rel)
|
|
359
|
+
elif dest_size != size:
|
|
360
|
+
size_mismatch.append((rel, size, dest_size))
|
|
361
|
+
|
|
362
|
+
typer.echo("\nVerification summary")
|
|
363
|
+
typer.echo("--------------------")
|
|
364
|
+
typer.echo(f"Files checked in source: {checked}")
|
|
365
|
+
typer.echo(f"Missing in destination: {len(missing)}")
|
|
366
|
+
typer.echo(f"Size mismatches: {len(size_mismatch)}")
|
|
367
|
+
|
|
368
|
+
if missing:
|
|
369
|
+
typer.echo("\nMissing files (relative to source root):")
|
|
370
|
+
for rel in missing[:20]:
|
|
371
|
+
typer.echo(f" - {rel}")
|
|
372
|
+
if len(missing) > 20:
|
|
373
|
+
typer.echo(f" ... and {len(missing) - 20} more.")
|
|
374
|
+
|
|
375
|
+
if size_mismatch:
|
|
376
|
+
typer.echo(
|
|
377
|
+
"\nFiles with size mismatch (relative path | source size -> dest size):"
|
|
378
|
+
)
|
|
379
|
+
for rel, s_size, d_size in size_mismatch[:20]:
|
|
380
|
+
typer.echo(f" - {rel} | {s_size} -> {d_size}")
|
|
381
|
+
if len(size_mismatch) > 20:
|
|
382
|
+
typer.echo(f" ... and {len(size_mismatch) - 20} more.")
|
|
383
|
+
|
|
384
|
+
if missing or size_mismatch:
|
|
385
|
+
typer.echo(
|
|
386
|
+
"\nBackup verification FAILED. Some files are missing or differ in size. "
|
|
387
|
+
"Please investigate before deleting anything.",
|
|
388
|
+
err=True,
|
|
389
|
+
)
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
typer.echo(
|
|
393
|
+
"\nBackup verification PASSED. All source files exist in destination with matching sizes."
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if not allow_delete:
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
confirm = typer.confirm(
|
|
400
|
+
"\nDo you want to delete all files under the source folder now?\n"
|
|
401
|
+
f" Source: {source_path}\n"
|
|
402
|
+
)
|
|
403
|
+
if not confirm:
|
|
404
|
+
typer.echo("\nNo files were deleted. Source remains intact.")
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
deleted = 0
|
|
408
|
+
delete_errors = 0
|
|
409
|
+
for f in source_path.rglob("*"):
|
|
410
|
+
if f.is_file():
|
|
411
|
+
try:
|
|
412
|
+
f.unlink()
|
|
413
|
+
deleted += 1
|
|
414
|
+
except Exception as e:
|
|
415
|
+
delete_errors += 1
|
|
416
|
+
typer.echo(
|
|
417
|
+
f"⚠ Warning: Could not delete file {f}: {e}", err=True
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
for d in sorted(
|
|
421
|
+
source_path.rglob("*"), key=lambda p: len(str(p)), reverse=True
|
|
422
|
+
):
|
|
423
|
+
if d.is_dir():
|
|
424
|
+
try:
|
|
425
|
+
d.rmdir()
|
|
426
|
+
except OSError:
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
typer.echo(f"\nSource cleanup complete. Deleted {deleted} file(s).")
|
|
430
|
+
if delete_errors:
|
|
431
|
+
typer.echo(
|
|
432
|
+
f"{delete_errors} file(s) could not be deleted. See warnings above.",
|
|
433
|
+
err=True,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def list_backups(archive_path: Path, subpath: str) -> None:
|
|
438
|
+
"""
|
|
439
|
+
List backup folders under a given subpath of the archive with file counts and sizes.
|
|
440
|
+
"""
|
|
441
|
+
base = archive_path / subpath
|
|
442
|
+
|
|
443
|
+
if not base.exists() or not base.is_dir():
|
|
444
|
+
typer.echo(f"No backup directory found at: {base}")
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
typer.echo(f"Listing backups under: {base}")
|
|
448
|
+
|
|
449
|
+
backups: list[tuple[str, int, int, float]] = []
|
|
450
|
+
|
|
451
|
+
for d in base.iterdir():
|
|
452
|
+
if not d.is_dir():
|
|
453
|
+
continue
|
|
454
|
+
file_count = 0
|
|
455
|
+
total_size = 0
|
|
456
|
+
latest_mtime = 0.0
|
|
457
|
+
for f in d.rglob("*"):
|
|
458
|
+
if f.is_file():
|
|
459
|
+
try:
|
|
460
|
+
st = f.stat()
|
|
461
|
+
file_count += 1
|
|
462
|
+
total_size += st.st_size
|
|
463
|
+
if st.st_mtime > latest_mtime:
|
|
464
|
+
latest_mtime = st.st_mtime
|
|
465
|
+
except (OSError, FileNotFoundError):
|
|
466
|
+
continue
|
|
467
|
+
backups.append((d.name, file_count, total_size, latest_mtime))
|
|
468
|
+
|
|
469
|
+
if not backups:
|
|
470
|
+
typer.echo("No backup folders found.")
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
backups.sort(key=lambda x: x[3], reverse=True)
|
|
474
|
+
|
|
475
|
+
typer.echo("\nBackups:")
|
|
476
|
+
typer.echo("Name\tFiles\tSize\tLast Modified")
|
|
477
|
+
from datetime import datetime
|
|
478
|
+
|
|
479
|
+
for name, count, size, mtime in backups:
|
|
480
|
+
if mtime:
|
|
481
|
+
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
|
482
|
+
else:
|
|
483
|
+
ts = "-"
|
|
484
|
+
typer.echo(f"{name}\t{count}\t{_format_bytes(size)}\t{ts}")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def restore_folder(
|
|
488
|
+
source_dir: str, dest_dir: str, dry_run: bool = False, overwrite: bool = False
|
|
489
|
+
) -> None:
|
|
490
|
+
"""
|
|
491
|
+
Restore (copy) a folder tree from source_dir to dest_dir.
|
|
492
|
+
"""
|
|
493
|
+
source_path = Path(source_dir)
|
|
494
|
+
dest_path = Path(dest_dir)
|
|
495
|
+
|
|
496
|
+
if not source_path.is_dir():
|
|
497
|
+
typer.echo(f"Source is not a valid directory: {source_path}", err=True)
|
|
498
|
+
raise typer.Exit(code=1)
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
502
|
+
except Exception as e:
|
|
503
|
+
typer.echo(
|
|
504
|
+
f"Could not create destination directory: {dest_path} ({e})", err=True
|
|
505
|
+
)
|
|
506
|
+
raise typer.Exit(code=1)
|
|
507
|
+
|
|
508
|
+
typer.echo(
|
|
509
|
+
f"{'Dry-running' if dry_run else 'Restoring'} folder from:\n"
|
|
510
|
+
f" Source: {source_path}\n"
|
|
511
|
+
f" Destination: {dest_path}\n"
|
|
512
|
+
f" Overwrite: {'yes' if overwrite else 'no (skip different existing files)'}"
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
files = [f for f in source_path.rglob("*") if f.is_file()]
|
|
516
|
+
|
|
517
|
+
if not files:
|
|
518
|
+
typer.echo("No files found in source directory.")
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
copied = 0
|
|
522
|
+
skipped = 0
|
|
523
|
+
conflicts = 0
|
|
524
|
+
errors = 0
|
|
525
|
+
|
|
526
|
+
with typer.progressbar(files, label="Restoring") as progress:
|
|
527
|
+
for src in progress:
|
|
528
|
+
try:
|
|
529
|
+
rel = src.relative_to(source_path)
|
|
530
|
+
dest_file = dest_path / rel
|
|
531
|
+
dest_dir_path = dest_file.parent
|
|
532
|
+
dest_dir_path.mkdir(parents=True, exist_ok=True)
|
|
533
|
+
|
|
534
|
+
src_size = src.stat().st_size
|
|
535
|
+
|
|
536
|
+
if dest_file.exists():
|
|
537
|
+
try:
|
|
538
|
+
dest_size = dest_file.stat().st_size
|
|
539
|
+
except (OSError, FileNotFoundError):
|
|
540
|
+
dest_size = -1
|
|
541
|
+
|
|
542
|
+
if dest_size == src_size:
|
|
543
|
+
skipped += 1
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
if not overwrite:
|
|
547
|
+
conflicts += 1
|
|
548
|
+
typer.echo(
|
|
549
|
+
f"\nConflict (sizes differ, not overwriting): {rel} "
|
|
550
|
+
f"({src_size} -> {dest_size})"
|
|
551
|
+
)
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
if dry_run:
|
|
555
|
+
copied += 1
|
|
556
|
+
typer.echo(f"\nWOULD OVERWRITE: {rel}")
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
if copy_and_verify(src, dest_dir_path):
|
|
560
|
+
copied += 1
|
|
561
|
+
else:
|
|
562
|
+
errors += 1
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
if dry_run:
|
|
566
|
+
copied += 1
|
|
567
|
+
typer.echo(f"\nWOULD COPY: {rel}")
|
|
568
|
+
continue
|
|
569
|
+
|
|
570
|
+
if copy_and_verify(src, dest_dir_path):
|
|
571
|
+
copied += 1
|
|
572
|
+
else:
|
|
573
|
+
errors += 1
|
|
574
|
+
|
|
575
|
+
except Exception as e:
|
|
576
|
+
errors += 1
|
|
577
|
+
typer.echo(
|
|
578
|
+
f"\n[ERROR] Could not process {src}: {e}", err=True
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
typer.echo("\nRestore summary")
|
|
582
|
+
typer.echo("---------------")
|
|
583
|
+
typer.echo(f"Files considered: {len(files)}")
|
|
584
|
+
typer.echo(f"Copied{' (simulated)' if dry_run else ''}: {copied}")
|
|
585
|
+
typer.echo(f"Skipped (already same): {skipped}")
|
|
586
|
+
typer.echo(f"Conflicts (different, not overwritten): {conflicts}")
|
|
587
|
+
if errors:
|
|
588
|
+
typer.echo(f"Errors: {errors}", err=True)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def list_duplicates(
|
|
592
|
+
root: Path, max_age_hours: Optional[int] = None
|
|
593
|
+
) -> list[tuple[tuple[str, int], list[Path]]]:
|
|
594
|
+
"""
|
|
595
|
+
Find all duplicate files (same name + size) under root. Returns list of
|
|
596
|
+
((name, size), [path1, path2, ...]) for each group with more than one path.
|
|
597
|
+
If max_age_hours is set, only consider files modified in the last N hours.
|
|
598
|
+
"""
|
|
599
|
+
import time
|
|
600
|
+
|
|
601
|
+
video_extensions = {
|
|
602
|
+
".mp4",
|
|
603
|
+
".mov",
|
|
604
|
+
".mxf",
|
|
605
|
+
".mts",
|
|
606
|
+
".avi",
|
|
607
|
+
".m4v",
|
|
608
|
+
".braw",
|
|
609
|
+
".r3d",
|
|
610
|
+
".crm",
|
|
611
|
+
}
|
|
612
|
+
cutoff = (time.time() - max_age_hours * 3600) if max_age_hours else None
|
|
613
|
+
by_key: dict[tuple[str, int], list[Path]] = {}
|
|
614
|
+
for f in root.rglob("*"):
|
|
615
|
+
if f.is_file() and f.suffix.lower() in video_extensions:
|
|
616
|
+
try:
|
|
617
|
+
st = f.stat()
|
|
618
|
+
if cutoff is not None and st.st_mtime < cutoff:
|
|
619
|
+
continue
|
|
620
|
+
key = (f.name, st.st_size)
|
|
621
|
+
by_key.setdefault(key, []).append(f)
|
|
622
|
+
except (OSError, FileNotFoundError):
|
|
623
|
+
pass
|
|
624
|
+
return [(key, paths) for key, paths in by_key.items() if len(paths) > 1]
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def remove_duplicates(
|
|
628
|
+
root: Path, dry_run: bool = False, max_age_hours: Optional[int] = None
|
|
629
|
+
) -> int:
|
|
630
|
+
"""
|
|
631
|
+
Within a root folder, find all video files and remove duplicates: same
|
|
632
|
+
(filename, size) in multiple places. Keeps one copy (first by path sort)
|
|
633
|
+
and deletes the rest. Returns number removed. If max_age_hours is set,
|
|
634
|
+
only consider files modified in the last N hours.
|
|
635
|
+
"""
|
|
636
|
+
import time
|
|
637
|
+
|
|
638
|
+
video_extensions = {
|
|
639
|
+
".mp4",
|
|
640
|
+
".mov",
|
|
641
|
+
".mxf",
|
|
642
|
+
".mts",
|
|
643
|
+
".avi",
|
|
644
|
+
".m4v",
|
|
645
|
+
".braw",
|
|
646
|
+
".r3d",
|
|
647
|
+
".crm",
|
|
648
|
+
}
|
|
649
|
+
cutoff = (time.time() - max_age_hours * 3600) if max_age_hours else None
|
|
650
|
+
by_key: dict[tuple[str, int], list[Path]] = {}
|
|
651
|
+
for f in root.rglob("*"):
|
|
652
|
+
if f.is_file() and f.suffix.lower() in video_extensions:
|
|
653
|
+
try:
|
|
654
|
+
st = f.stat()
|
|
655
|
+
if cutoff is not None and st.st_mtime < cutoff:
|
|
656
|
+
continue
|
|
657
|
+
key = (f.name, st.st_size)
|
|
658
|
+
by_key.setdefault(key, []).append(f)
|
|
659
|
+
except (OSError, FileNotFoundError):
|
|
660
|
+
pass
|
|
661
|
+
removed = 0
|
|
662
|
+
for key, paths in by_key.items():
|
|
663
|
+
if len(paths) <= 1:
|
|
664
|
+
continue
|
|
665
|
+
paths_sorted = sorted(paths, key=lambda p: str(p))
|
|
666
|
+
keep, duplicates = paths_sorted[0], paths_sorted[1:]
|
|
667
|
+
for dup in duplicates:
|
|
668
|
+
if dry_run:
|
|
669
|
+
typer.echo(f" [dry-run] would remove duplicate: {dup}")
|
|
670
|
+
else:
|
|
671
|
+
try:
|
|
672
|
+
dup.unlink()
|
|
673
|
+
try:
|
|
674
|
+
rel = dup.relative_to(root)
|
|
675
|
+
except ValueError:
|
|
676
|
+
rel = dup
|
|
677
|
+
typer.echo(f" Removed duplicate: {rel}")
|
|
678
|
+
removed += 1
|
|
679
|
+
except OSError as e:
|
|
680
|
+
typer.echo(
|
|
681
|
+
f" [ERROR] Could not remove {dup}: {e}", err=True
|
|
682
|
+
)
|
|
683
|
+
return removed
|
|
684
|
+
|