morphconv 1.0.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.
morph/__init__.py ADDED
File without changes
morph/batch.py ADDED
@@ -0,0 +1,492 @@
1
+ """
2
+ batch.py — parallel batch conversion with live Rich progress display.
3
+
4
+ Usage from the CLI:
5
+
6
+ morph batch '*.mp4' mp3
7
+ morph batch ./videos/ mp3 --recursive --workers 4
8
+ morph batch '*.flac' '*.wav' mp3 --out-dir ./converted/ --skip-existing
9
+ morph batch '*.mp4' mp3 --rename '{stem}_audio' --bitrate 192k
10
+
11
+ All the heavy lifting lives here; cli.py just parses args and calls
12
+ `run_batch()`. The live display uses a Rich Live table that updates
13
+ every 200 ms showing per-file status, an overall progress bar, and a
14
+ summary report on completion.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import glob
20
+ import tempfile
21
+ import threading
22
+ import time
23
+ from concurrent.futures import ThreadPoolExecutor, as_completed, Future
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum
26
+ from pathlib import Path
27
+ from typing import Any, Optional
28
+
29
+ from rich import box
30
+ from rich.console import Console
31
+ from rich.live import Live
32
+ from rich.panel import Panel
33
+ from rich.progress import BarColumn, Progress, TextColumn
34
+ from rich.table import Table as RichTable
35
+ from rich.theme import Theme
36
+
37
+ from . import deps
38
+ from .history import HistoryEntry, append_entry, generate_batch_id, make_entry
39
+ from .registry import ConversionResult, ConverterSpec, detect_format, registry
40
+
41
+
42
+ # ── data types ────────────────────────────────────────────────────────────────
43
+
44
+ class JobState(Enum):
45
+ WAITING = "waiting"
46
+ RUNNING = "running"
47
+ DONE = "done"
48
+ FAILED = "failed"
49
+ SKIPPED = "skipped"
50
+
51
+
52
+ @dataclass
53
+ class Job:
54
+ input_path: Path
55
+ output_path: Path
56
+ src_fmt: str
57
+ dst_fmt: str
58
+ conv_path: list[ConverterSpec]
59
+ state: JobState = JobState.WAITING
60
+ elapsed: float = 0.0
61
+ error: Optional[str] = None
62
+ input_size: int = 0
63
+ output_size: int = 0
64
+
65
+
66
+ @dataclass
67
+ class BatchResult:
68
+ total: int = 0
69
+ converted: int = 0
70
+ failed: int = 0
71
+ skipped: int = 0
72
+ elapsed: float = 0.0
73
+ errors: list[tuple[str, str]] = field(default_factory=list)
74
+
75
+
76
+ # ── input collection ──────────────────────────────────────────────────────────
77
+
78
+ def collect_inputs(
79
+ patterns: list[str],
80
+ target_fmt: str,
81
+ *,
82
+ recursive: bool = False,
83
+ out_dir: Optional[Path] = None,
84
+ mirror: bool = False,
85
+ rename_template: Optional[str] = None,
86
+ skip_existing: bool = False,
87
+ newer_only: bool = False,
88
+ base_dir: Optional[Path] = None,
89
+ ) -> list[Job]:
90
+ """
91
+ Resolve input patterns / directories into a list of Job objects,
92
+ each with its input_path, computed output_path, and conversion path.
93
+ """
94
+ raw_files: list[Path] = []
95
+
96
+ for pattern in patterns:
97
+ p = Path(pattern)
98
+ if p.is_dir():
99
+ if recursive:
100
+ raw_files.extend(f for f in p.rglob("*") if f.is_file())
101
+ else:
102
+ raw_files.extend(f for f in p.iterdir() if f.is_file())
103
+ else:
104
+ expanded = glob.glob(pattern, recursive=recursive)
105
+ raw_files.extend(Path(f) for f in expanded if Path(f).is_file())
106
+
107
+ # Deduplicate, preserving order
108
+ seen: set[Path] = set()
109
+ files: list[Path] = []
110
+ for f in raw_files:
111
+ resolved = f.resolve()
112
+ if resolved not in seen:
113
+ seen.add(resolved)
114
+ files.append(f)
115
+
116
+ target_fmt = target_fmt.lower().lstrip(".")
117
+ jobs: list[Job] = []
118
+
119
+ for file in files:
120
+ src_fmt = detect_format(file)
121
+ if src_fmt == target_fmt:
122
+ # Same format → skip
123
+ jobs.append(Job(
124
+ input_path=file,
125
+ output_path=file,
126
+ src_fmt=src_fmt,
127
+ dst_fmt=target_fmt,
128
+ conv_path=[],
129
+ state=JobState.SKIPPED,
130
+ error="same format",
131
+ ))
132
+ continue
133
+
134
+ conv_path = registry.find_path(src_fmt, target_fmt)
135
+ if conv_path is None:
136
+ jobs.append(Job(
137
+ input_path=file,
138
+ output_path=file,
139
+ src_fmt=src_fmt,
140
+ dst_fmt=target_fmt,
141
+ conv_path=[],
142
+ state=JobState.SKIPPED,
143
+ error=f"no route from .{src_fmt}",
144
+ ))
145
+ continue
146
+
147
+ output_path = _compute_output(
148
+ file, target_fmt,
149
+ out_dir=out_dir, mirror=mirror,
150
+ rename_template=rename_template,
151
+ base_dir=base_dir,
152
+ )
153
+
154
+ if skip_existing and output_path.exists():
155
+ jobs.append(Job(
156
+ input_path=file, output_path=output_path,
157
+ src_fmt=src_fmt, dst_fmt=target_fmt, conv_path=conv_path,
158
+ state=JobState.SKIPPED, error="output exists",
159
+ ))
160
+ continue
161
+
162
+ if newer_only and output_path.exists():
163
+ if output_path.stat().st_mtime >= file.stat().st_mtime:
164
+ jobs.append(Job(
165
+ input_path=file, output_path=output_path,
166
+ src_fmt=src_fmt, dst_fmt=target_fmt, conv_path=conv_path,
167
+ state=JobState.SKIPPED, error="output is newer",
168
+ ))
169
+ continue
170
+
171
+ jobs.append(Job(
172
+ input_path=file, output_path=output_path,
173
+ src_fmt=src_fmt, dst_fmt=target_fmt, conv_path=conv_path,
174
+ input_size=file.stat().st_size if file.exists() else 0,
175
+ ))
176
+
177
+ return jobs
178
+
179
+
180
+ def _compute_output(
181
+ input_path: Path,
182
+ target_fmt: str,
183
+ *,
184
+ out_dir: Optional[Path] = None,
185
+ mirror: bool = False,
186
+ rename_template: Optional[str] = None,
187
+ base_dir: Optional[Path] = None,
188
+ ) -> Path:
189
+ stem = input_path.stem
190
+ src_fmt = detect_format(input_path)
191
+
192
+ if rename_template:
193
+ name = rename_template.format(
194
+ stem=stem, fmt=target_fmt, src_fmt=src_fmt,
195
+ ) + f".{target_fmt}"
196
+ else:
197
+ name = f"{stem}.{target_fmt}"
198
+
199
+ if out_dir:
200
+ if mirror and base_dir:
201
+ try:
202
+ rel = input_path.parent.relative_to(base_dir)
203
+ except ValueError:
204
+ rel = Path()
205
+ dest = out_dir / rel / name
206
+ else:
207
+ dest = out_dir / name
208
+ dest.parent.mkdir(parents=True, exist_ok=True)
209
+ return dest
210
+
211
+ return input_path.parent / name
212
+
213
+
214
+ # ── per-job conversion (runs in a thread) ─────────────────────────────────────
215
+
216
+ def _convert_one(
217
+ job: Job,
218
+ options_dict: dict[str, Any],
219
+ lock: threading.Lock,
220
+ ) -> Job:
221
+ """Run the full conversion chain for a single job, updating its state."""
222
+ with lock:
223
+ job.state = JobState.RUNNING
224
+
225
+ start = time.perf_counter()
226
+ current_input = job.input_path
227
+
228
+ try:
229
+ with tempfile.TemporaryDirectory() as tmpdir:
230
+ for i, spec in enumerate(job.conv_path):
231
+ is_last = i == len(job.conv_path) - 1
232
+ hop_out = (
233
+ job.output_path if is_last
234
+ else Path(tmpdir) / f"hop{i}.{spec.dst}"
235
+ )
236
+ hop_options = {
237
+ opt.name: options_dict.get(opt.name, opt.default)
238
+ for opt in spec.options
239
+ }
240
+ result = spec.func(current_input, hop_out, **hop_options)
241
+ current_input = result.output
242
+ except Exception as exc:
243
+ with lock:
244
+ job.state = JobState.FAILED
245
+ job.elapsed = time.perf_counter() - start
246
+ job.error = str(exc)
247
+ return job
248
+
249
+ with lock:
250
+ job.state = JobState.DONE
251
+ job.elapsed = time.perf_counter() - start
252
+ if job.output_path.exists():
253
+ job.output_size = job.output_path.stat().st_size
254
+
255
+ return job
256
+
257
+
258
+ # ── size formatting ───────────────────────────────────────────────────────────
259
+
260
+ def _fmt_size(n: int) -> str:
261
+ if n == 0:
262
+ return ""
263
+ for unit in ("B", "KB", "MB", "GB"):
264
+ if n < 1024:
265
+ return f"{n:.1f}{unit}" if unit != "B" else f"{n}{unit}"
266
+ n /= 1024
267
+ return f"{n:.1f}TB"
268
+
269
+
270
+ # ── live display ──────────────────────────────────────────────────────────────
271
+
272
+ _SYMBOLS = {
273
+ JobState.WAITING: "[dim]·[/dim]",
274
+ JobState.RUNNING: "[cyan]▶[/cyan]",
275
+ JobState.DONE: "[green]✓[/green]",
276
+ JobState.FAILED: "[red]✗[/red]",
277
+ JobState.SKIPPED: "[yellow]⊘[/yellow]",
278
+ }
279
+
280
+ _STATUS_STYLE = {
281
+ JobState.WAITING: "dim",
282
+ JobState.RUNNING: "cyan",
283
+ JobState.DONE: "green",
284
+ JobState.FAILED: "red",
285
+ JobState.SKIPPED: "yellow",
286
+ }
287
+
288
+
289
+ def _build_display(jobs: list[Job], target_fmt: str, elapsed: float) -> RichTable:
290
+ """Build the full live display table."""
291
+ done_count = sum(1 for j in jobs if j.state == JobState.DONE)
292
+ fail_count = sum(1 for j in jobs if j.state == JobState.FAILED)
293
+ skip_count = sum(1 for j in jobs if j.state == JobState.SKIPPED)
294
+ total = len(jobs)
295
+ convertible = total - skip_count
296
+
297
+ tbl = RichTable(
298
+ box=box.ROUNDED, border_style="cyan",
299
+ title=f"morph batch — {total} files → {target_fmt}",
300
+ title_style="bold cyan",
301
+ caption=(
302
+ f"[green]✓ {done_count}[/green] "
303
+ f"[red]✗ {fail_count}[/red] "
304
+ f"[yellow]⊘ {skip_count}[/yellow] "
305
+ f"[dim]⏱ {elapsed:.1f}s[/dim]"
306
+ ),
307
+ show_edge=True,
308
+ pad_edge=True,
309
+ )
310
+ tbl.add_column("", width=2, no_wrap=True)
311
+ tbl.add_column("File", style="white", ratio=3, no_wrap=True)
312
+ tbl.add_column("Status", ratio=2, no_wrap=True)
313
+ tbl.add_column("Time", justify="right", width=8, no_wrap=True)
314
+ tbl.add_column("Size", justify="right", width=18, no_wrap=True)
315
+
316
+ for j in jobs:
317
+ sym = _SYMBOLS[j.state]
318
+ style = _STATUS_STYLE[j.state]
319
+
320
+ name = j.input_path.name
321
+ if len(name) > 35:
322
+ name = name[:32] + "..."
323
+
324
+ if j.state == JobState.DONE:
325
+ status = "done"
326
+ time_str = f"{j.elapsed:.1f}s"
327
+ in_s = _fmt_size(j.input_size)
328
+ out_s = _fmt_size(j.output_size)
329
+ size_str = f"{in_s} → {out_s}" if in_s else ""
330
+ elif j.state == JobState.RUNNING:
331
+ status = "converting…"
332
+ time_str = ""
333
+ size_str = _fmt_size(j.input_size)
334
+ elif j.state == JobState.FAILED:
335
+ status = j.error or "error"
336
+ if len(status) > 30:
337
+ status = status[:27] + "..."
338
+ time_str = f"{j.elapsed:.1f}s"
339
+ size_str = ""
340
+ elif j.state == JobState.SKIPPED:
341
+ status = j.error or "skipped"
342
+ time_str = ""
343
+ size_str = ""
344
+ else:
345
+ status = "waiting"
346
+ time_str = ""
347
+ size_str = ""
348
+
349
+ tbl.add_row(sym, name, f"[{style}]{status}[/{style}]", time_str, size_str)
350
+
351
+ return tbl
352
+
353
+
354
+ # ── main entry point ──────────────────────────────────────────────────────────
355
+
356
+ def run_batch(
357
+ jobs: list[Job],
358
+ target_fmt: str,
359
+ options_dict: dict[str, Any],
360
+ console: Console,
361
+ *,
362
+ workers: int = 4,
363
+ fail_fast: bool = False,
364
+ error_log: Optional[Path] = None,
365
+ quiet: bool = False,
366
+ ) -> BatchResult:
367
+ """
368
+ Execute all convertible jobs in parallel with a live Rich display,
369
+ returning a BatchResult summary.
370
+ """
371
+ batch_id = generate_batch_id()
372
+ lock = threading.Lock()
373
+ start_time = time.perf_counter()
374
+
375
+ convertible = [j for j in jobs if j.state == JobState.WAITING]
376
+ result = BatchResult(total=len(jobs), skipped=sum(1 for j in jobs if j.state == JobState.SKIPPED))
377
+
378
+ if not convertible:
379
+ if not quiet:
380
+ console.print("[warning]No files to convert.[/warning]")
381
+ return result
382
+
383
+ # Pre-check dependencies for all unique routes
384
+ needed: set[str] = set()
385
+ for j in convertible:
386
+ needed.update(registry.required_binaries(j.conv_path))
387
+ if needed and not deps.ensure_all(list(needed), console, assume_yes=False):
388
+ console.print("[error]✗ Required dependency not available — aborting batch.[/error]")
389
+ return result
390
+
391
+ # Error log file handle
392
+ err_fh = None
393
+ if error_log:
394
+ try:
395
+ err_fh = open(error_log, "a", encoding="utf-8")
396
+ except OSError:
397
+ console.print(f"[warning]Could not open error log: {error_log}[/warning]")
398
+
399
+ stop_flag = threading.Event()
400
+
401
+ def _submit_and_track(executor: ThreadPoolExecutor) -> None:
402
+ futures: dict[Future, Job] = {}
403
+ for j in convertible:
404
+ if stop_flag.is_set():
405
+ break
406
+ f = executor.submit(_convert_one, j, options_dict, lock)
407
+ futures[f] = j
408
+
409
+ for future in as_completed(futures):
410
+ j = futures[future]
411
+ try:
412
+ future.result()
413
+ except Exception as exc:
414
+ with lock:
415
+ j.state = JobState.FAILED
416
+ j.error = str(exc)
417
+
418
+ # History
419
+ route = " → ".join([j.src_fmt] + [s.dst for s in j.conv_path])
420
+ backends = ", ".join(dict.fromkeys(s.backend for s in j.conv_path))
421
+ entry = make_entry(
422
+ j.input_path, j.src_fmt, j.output_path, j.dst_fmt,
423
+ route, backends, j.state == JobState.DONE, j.elapsed,
424
+ mode="batch", batch_id=batch_id,
425
+ error=j.error,
426
+ )
427
+ append_entry(entry)
428
+
429
+ if j.state == JobState.FAILED:
430
+ if err_fh:
431
+ try:
432
+ err_fh.write(f"{j.input_path}: {j.error}\n")
433
+ err_fh.flush()
434
+ except OSError:
435
+ pass
436
+ if fail_fast:
437
+ stop_flag.set()
438
+
439
+ if quiet:
440
+ # No live display — just run and print summary
441
+ with ThreadPoolExecutor(max_workers=workers) as executor:
442
+ _submit_and_track(executor)
443
+ else:
444
+ with Live(
445
+ _build_display(jobs, target_fmt, 0),
446
+ console=console, refresh_per_second=5, transient=False,
447
+ ) as live:
448
+ def _refresh_loop() -> None:
449
+ while not _done.is_set():
450
+ elapsed = time.perf_counter() - start_time
451
+ live.update(_build_display(jobs, target_fmt, elapsed))
452
+ _done.wait(0.2)
453
+ elapsed = time.perf_counter() - start_time
454
+ live.update(_build_display(jobs, target_fmt, elapsed))
455
+
456
+ _done = threading.Event()
457
+ refresh_thread = threading.Thread(target=_refresh_loop, daemon=True)
458
+ refresh_thread.start()
459
+
460
+ with ThreadPoolExecutor(max_workers=workers) as executor:
461
+ _submit_and_track(executor)
462
+
463
+ _done.set()
464
+ refresh_thread.join(timeout=2)
465
+
466
+ if err_fh:
467
+ err_fh.close()
468
+
469
+ total_elapsed = time.perf_counter() - start_time
470
+ result.converted = sum(1 for j in jobs if j.state == JobState.DONE)
471
+ result.failed = sum(1 for j in jobs if j.state == JobState.FAILED)
472
+ result.elapsed = total_elapsed
473
+ result.errors = [(str(j.input_path.name), j.error or "unknown") for j in jobs if j.state == JobState.FAILED]
474
+
475
+ # Summary panel
476
+ if not quiet:
477
+ console.print()
478
+ parts = [
479
+ f"[green]✓ {result.converted} converted[/green]",
480
+ f"[red]✗ {result.failed} failed[/red]",
481
+ ]
482
+ if result.skipped:
483
+ parts.append(f"[yellow]⊘ {result.skipped} skipped[/yellow]")
484
+ parts.append(f"[dim]⏱ {result.elapsed:.1f}s[/dim]")
485
+ console.print(Panel.fit(" ".join(parts), title="morph batch — complete", border_style="cyan"))
486
+
487
+ if result.errors:
488
+ console.print()
489
+ for name, err in result.errors:
490
+ console.print(f" [red]✗[/red] {name} — {err}")
491
+
492
+ return result