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.
@@ -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
+