mkv-episode-matcher 0.3.3__py3-none-any.whl → 1.0.0__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.
Files changed (38) hide show
  1. mkv_episode_matcher/__init__.py +8 -0
  2. mkv_episode_matcher/__main__.py +2 -177
  3. mkv_episode_matcher/asr_models.py +506 -0
  4. mkv_episode_matcher/cli.py +558 -0
  5. mkv_episode_matcher/core/config_manager.py +100 -0
  6. mkv_episode_matcher/core/engine.py +577 -0
  7. mkv_episode_matcher/core/matcher.py +214 -0
  8. mkv_episode_matcher/core/models.py +91 -0
  9. mkv_episode_matcher/core/providers/asr.py +85 -0
  10. mkv_episode_matcher/core/providers/subtitles.py +341 -0
  11. mkv_episode_matcher/core/utils.py +148 -0
  12. mkv_episode_matcher/episode_identification.py +550 -118
  13. mkv_episode_matcher/subtitle_utils.py +82 -0
  14. mkv_episode_matcher/tmdb_client.py +56 -14
  15. mkv_episode_matcher/ui/flet_app.py +708 -0
  16. mkv_episode_matcher/utils.py +262 -139
  17. mkv_episode_matcher-1.0.0.dist-info/METADATA +242 -0
  18. mkv_episode_matcher-1.0.0.dist-info/RECORD +23 -0
  19. {mkv_episode_matcher-0.3.3.dist-info → mkv_episode_matcher-1.0.0.dist-info}/WHEEL +1 -1
  20. mkv_episode_matcher-1.0.0.dist-info/licenses/LICENSE +21 -0
  21. mkv_episode_matcher/config.py +0 -82
  22. mkv_episode_matcher/episode_matcher.py +0 -100
  23. mkv_episode_matcher/libraries/pgs2srt/.gitignore +0 -2
  24. mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/SubZero.py +0 -321
  25. mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/dictionaries/data.py +0 -16700
  26. mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/post_processing.py +0 -260
  27. mkv_episode_matcher/libraries/pgs2srt/README.md +0 -26
  28. mkv_episode_matcher/libraries/pgs2srt/__init__.py +0 -0
  29. mkv_episode_matcher/libraries/pgs2srt/imagemaker.py +0 -89
  30. mkv_episode_matcher/libraries/pgs2srt/pgs2srt.py +0 -150
  31. mkv_episode_matcher/libraries/pgs2srt/pgsreader.py +0 -225
  32. mkv_episode_matcher/libraries/pgs2srt/requirements.txt +0 -4
  33. mkv_episode_matcher/mkv_to_srt.py +0 -302
  34. mkv_episode_matcher/speech_to_text.py +0 -90
  35. mkv_episode_matcher-0.3.3.dist-info/METADATA +0 -125
  36. mkv_episode_matcher-0.3.3.dist-info/RECORD +0 -25
  37. {mkv_episode_matcher-0.3.3.dist-info → mkv_episode_matcher-1.0.0.dist-info}/entry_points.txt +0 -0
  38. {mkv_episode_matcher-0.3.3.dist-info → mkv_episode_matcher-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,558 @@
1
+ """
2
+ Unified CLI Interface for MKV Episode Matcher V2
3
+
4
+ This module provides a single, intuitive command-line interface that handles
5
+ all use cases with intelligent auto-detection and minimal configuration.
6
+ """
7
+
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from loguru import logger
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.text import Text
17
+
18
+ # Configure logger
19
+ logger.remove()
20
+ logger.add(sys.stderr, level="INFO")
21
+
22
+ from rich.table import Table
23
+
24
+ from mkv_episode_matcher.core.config_manager import get_config_manager
25
+ from mkv_episode_matcher.core.engine import MatchEngineV2
26
+ from mkv_episode_matcher.core.models import Config
27
+
28
+ app = typer.Typer(
29
+ name="mkv-match",
30
+ help="MKV Episode Matcher - Intelligent TV episode identification and renaming",
31
+ no_args_is_help=True,
32
+ )
33
+
34
+ console = Console()
35
+
36
+
37
+ def print_banner():
38
+ """Print application banner."""
39
+ banner = Text("MKV Episode Matcher", style="bold blue")
40
+ console.print(
41
+ Panel(banner, subtitle="Intelligent episode matching with zero-config setup")
42
+ )
43
+
44
+
45
+ @app.command()
46
+ def match(
47
+ path: Path = typer.Argument(
48
+ ..., help="Path to MKV file, series folder, or entire library", exists=True
49
+ ),
50
+ # Core options
51
+ season: int | None = typer.Option(
52
+ None, "--season", "-s", help="Override season number for all files"
53
+ ),
54
+ recursive: bool = typer.Option(
55
+ True,
56
+ "--recursive/--no-recursive",
57
+ "-r/-nr",
58
+ help="Search recursively in directories",
59
+ ),
60
+ dry_run: bool = typer.Option(
61
+ False, "--dry-run", "-d", help="Preview changes without renaming files"
62
+ ),
63
+ # Output options
64
+ output_dir: Path | None = typer.Option(
65
+ None,
66
+ "--output-dir",
67
+ "-o",
68
+ help="Copy renamed files to this directory instead of renaming in place",
69
+ ),
70
+ json_output: bool = typer.Option(
71
+ False, "--json", help="Output results in JSON format for automation"
72
+ ),
73
+ # Quality options
74
+ confidence_threshold: float | None = typer.Option(
75
+ None,
76
+ "--confidence",
77
+ "-c",
78
+ min=0.0,
79
+ max=1.0,
80
+ help="Minimum confidence score for matches (0.0-1.0)",
81
+ ),
82
+ # Subtitle options
83
+ download_subs: bool = typer.Option(
84
+ True,
85
+ "--download-subs/--no-download-subs",
86
+ help="Automatically download subtitles if not found locally",
87
+ ),
88
+ ):
89
+ """
90
+ Process MKV files with intelligent episode matching.
91
+
92
+ Automatically detects whether you're processing:
93
+ • A single file
94
+ • A series folder
95
+ • An entire library
96
+
97
+ Examples:
98
+
99
+ # Process a single file
100
+ mkv-match episode.mkv
101
+
102
+ # Process a series season
103
+ mkv-match "/media/Breaking Bad/Season 1/"
104
+
105
+ # Process entire library
106
+ mkv-match /media/tv-shows/ --recursive
107
+
108
+ # Dry run with custom output
109
+ mkv-match episode.mkv --dry-run --output-dir ./renamed/
110
+
111
+ # Automation mode
112
+ mkv-match show/ --json --confidence 0.8
113
+ """
114
+
115
+ if not json_output:
116
+ print_banner()
117
+
118
+ # Load configuration
119
+ try:
120
+ cm = get_config_manager()
121
+ config = cm.load()
122
+
123
+ # Override config with CLI options
124
+ if confidence_threshold is not None:
125
+ config.min_confidence = confidence_threshold
126
+
127
+ if not download_subs:
128
+ config.sub_provider = "local"
129
+
130
+ except Exception as e:
131
+ if json_output:
132
+ print(json.dumps({"error": f"Configuration error: {e}"}))
133
+ else:
134
+ console.print(f"[red]Configuration error: {e}[/red]")
135
+ sys.exit(1)
136
+
137
+ # Initialize engine
138
+ try:
139
+ engine = MatchEngineV2(config)
140
+ except Exception as e:
141
+ if json_output:
142
+ print(json.dumps({"error": f"Engine initialization failed: {e}"}))
143
+ else:
144
+ console.print(f"[red]Failed to initialize engine: {e}[/red]")
145
+ sys.exit(1)
146
+
147
+ # Detect processing mode
148
+ if path.is_file():
149
+ mode = "single_file"
150
+ elif path.is_dir():
151
+ # Count MKV files to determine if it's a series or library
152
+ mkv_count = len(list(path.rglob("*.mkv") if recursive else path.glob("*.mkv")))
153
+ if mkv_count == 0:
154
+ if json_output:
155
+ print(json.dumps({"error": "No MKV files found"}))
156
+ else:
157
+ console.print("[yellow]No MKV files found[/yellow]")
158
+ sys.exit(0)
159
+ elif mkv_count <= 30: # Arbitrary threshold
160
+ mode = "series_folder"
161
+ else:
162
+ mode = "library"
163
+ else:
164
+ if json_output:
165
+ print(json.dumps({"error": "Invalid path"}))
166
+ else:
167
+ console.print("[red]Invalid path[/red]")
168
+ sys.exit(1)
169
+
170
+ if not json_output:
171
+ mode_descriptions = {
172
+ "single_file": "Processing single file",
173
+ "series_folder": "Processing series folder",
174
+ "library": "Processing entire library",
175
+ }
176
+ console.print(f"[blue]{mode_descriptions[mode]}[/blue]: {path}")
177
+
178
+ if dry_run:
179
+ console.print("[yellow]DRY RUN MODE - No files will be renamed[/yellow]")
180
+
181
+ # Process files
182
+ try:
183
+ results, failures = engine.process_path(
184
+ path=path,
185
+ season_override=season,
186
+ recursive=recursive,
187
+ dry_run=dry_run,
188
+ output_dir=output_dir,
189
+ json_output=json_output,
190
+ confidence_threshold=confidence_threshold,
191
+ )
192
+
193
+ # Output results
194
+ if json_output:
195
+ output_data = {
196
+ "mode": mode,
197
+ "path": str(path),
198
+ "total_matches": len(results),
199
+ "total_failures": len(failures),
200
+ "dry_run": dry_run,
201
+ "results": json.loads(engine.export_results(results)),
202
+ "failures": [
203
+ {
204
+ "original_file": str(f.original_file),
205
+ "reason": f.reason,
206
+ "confidence": f.confidence,
207
+ }
208
+ for f in failures
209
+ ],
210
+ }
211
+ print(json.dumps(output_data, indent=2))
212
+ else:
213
+ # Rich console summary
214
+ if results or failures:
215
+ _display_comprehensive_summary(
216
+ results, failures, dry_run, output_dir, console
217
+ )
218
+ else:
219
+ console.print("[yellow]No MKV files processed[/yellow]")
220
+
221
+ except Exception as e:
222
+ if json_output:
223
+ print(json.dumps({"error": f"Processing failed: {e}"}))
224
+ else:
225
+ console.print(f"[red]Processing failed: {e}[/red]")
226
+ sys.exit(1)
227
+
228
+
229
+ @app.command()
230
+ def config(
231
+ show_cache_dir: bool = typer.Option(
232
+ False, "--show-cache-dir", help="Show current cache directory location"
233
+ ),
234
+ reset: bool = typer.Option(
235
+ False, "--reset", help="Reset configuration to defaults"
236
+ ),
237
+ ):
238
+ """
239
+ Configure MKV Episode Matcher settings.
240
+
241
+ Most settings are auto-configured, but you can customize:
242
+ • Cache directory location
243
+ • Default confidence thresholds
244
+ • ASR model preferences
245
+ """
246
+
247
+ cm = get_config_manager()
248
+
249
+ if show_cache_dir:
250
+ config = cm.load()
251
+ console.print(f"Cache directory: [blue]{config.cache_dir}[/blue]")
252
+ return
253
+
254
+ if reset:
255
+ config = Config() # Default config
256
+ cm.save(config)
257
+ console.print("[green]Configuration reset to defaults[/green]")
258
+ return
259
+
260
+ # Interactive configuration
261
+ console.print(Panel("MKV Episode Matcher Configuration"))
262
+
263
+ config = cm.load()
264
+
265
+ # Cache directory
266
+ current_cache = str(config.cache_dir)
267
+ new_cache = typer.prompt(
268
+ "Cache directory", default=current_cache, show_default=True
269
+ )
270
+ if new_cache != current_cache:
271
+ config.cache_dir = Path(new_cache)
272
+
273
+ # Confidence threshold
274
+ current_confidence = config.min_confidence
275
+ new_confidence = typer.prompt(
276
+ "Minimum confidence threshold (0.0-1.0)",
277
+ type=float,
278
+ default=current_confidence,
279
+ show_default=True,
280
+ )
281
+ if 0.0 <= new_confidence <= 1.0:
282
+ config.min_confidence = new_confidence
283
+
284
+ # ASR provider
285
+ current_asr = config.asr_provider
286
+ new_asr = typer.prompt(
287
+ "ASR provider (parakeet)",
288
+ default=current_asr,
289
+ show_default=True,
290
+ )
291
+ if new_asr in ["parakeet"]:
292
+ config.asr_provider = new_asr
293
+
294
+ # Subtitle provider
295
+ current_sub = config.sub_provider
296
+ new_sub = typer.prompt(
297
+ "Subtitle provider (local/opensubtitles)",
298
+ default=current_sub,
299
+ show_default=True,
300
+ )
301
+ if new_sub in ["local", "opensubtitles"]:
302
+ config.sub_provider = new_sub
303
+
304
+ # OpenSubtitles config
305
+ if config.sub_provider == "opensubtitles":
306
+ console.print("\n[bold]OpenSubtitles Configuration:[/bold]")
307
+
308
+ current_api = config.open_subtitles_api_key or ""
309
+ new_api = typer.prompt("API Key", default=current_api, show_default=True)
310
+ if new_api.strip():
311
+ config.open_subtitles_api_key = new_api.strip()
312
+
313
+ current_user = config.open_subtitles_username or ""
314
+ new_user = typer.prompt("Username", default=current_user, show_default=True)
315
+ if new_user.strip():
316
+ config.open_subtitles_username = new_user.strip()
317
+
318
+ current_pass = config.open_subtitles_password or ""
319
+ new_pass = typer.prompt(
320
+ "Password", default=current_pass, show_default=False, hide_input=True
321
+ )
322
+ if new_pass.strip():
323
+ config.open_subtitles_password = new_pass.strip()
324
+
325
+ # TMDB API key (optional)
326
+ current_tmdb = config.tmdb_api_key or ""
327
+ new_tmdb = typer.prompt(
328
+ "TMDb API key (optional, for episode titles)",
329
+ default=current_tmdb,
330
+ show_default=False,
331
+ )
332
+ if new_tmdb.strip():
333
+ config.tmdb_api_key = new_tmdb.strip()
334
+
335
+ # Save configuration
336
+ cm.save(config)
337
+ console.print("[green]Configuration saved successfully[/green]")
338
+
339
+
340
+ @app.command()
341
+ def info():
342
+ """
343
+ Show system information and available models.
344
+ """
345
+ console.print(Panel("MKV Episode Matcher - System Information"))
346
+
347
+ try:
348
+ from mkv_episode_matcher.asr_models import list_available_models
349
+
350
+ models = list_available_models()
351
+
352
+ console.print("\n[bold]Available ASR Models:[/bold]")
353
+ for model_type, info in models.items():
354
+ if info.get("available"):
355
+ status = "[green]Available[/green]"
356
+ model_list = ", ".join(info.get("models", [])[:3]) # Show first 3
357
+ console.print(f" {model_type}: {status}")
358
+ console.print(f" Models: {model_list}")
359
+ else:
360
+ status = "[red]Not available[/red]"
361
+ error = info.get("error", "Unknown error")
362
+ console.print(f" {model_type}: {status} ({error})")
363
+
364
+ except Exception as e:
365
+ console.print(f"[red]Error checking models: {e}[/red]")
366
+
367
+ # Configuration info
368
+ try:
369
+ cm = get_config_manager()
370
+ config = cm.load()
371
+
372
+ console.print("\n[bold]Current Configuration:[/bold]")
373
+ console.print(f" Cache directory: {config.cache_dir}")
374
+ console.print(f" ASR provider: {config.asr_provider}")
375
+ console.print(f" Subtitle provider: {config.sub_provider}")
376
+ console.print(f" Confidence threshold: {config.min_confidence}")
377
+
378
+ except Exception as e:
379
+ console.print(f"[red]Error loading config: {e}[/red]")
380
+
381
+
382
+ @app.command()
383
+ def version():
384
+ """Show version information."""
385
+ try:
386
+ import mkv_episode_matcher
387
+
388
+ version = mkv_episode_matcher.__version__
389
+ except AttributeError:
390
+ version = "unknown"
391
+
392
+ console.print(f"MKV Episode Matcher v{version}")
393
+
394
+
395
+ def _display_comprehensive_summary(results, failures, dry_run, output_dir, console):
396
+ """Display a comprehensive summary of matching results."""
397
+ from collections import defaultdict
398
+
399
+ console.print("\n[bold green]Processing Complete![/bold green]")
400
+ console.print(f"[blue]Successfully processed {len(results)} files[/blue]")
401
+ if failures:
402
+ console.print(f"[red]Failed to match {len(failures)} files[/red]\n")
403
+ else:
404
+ console.print("\n")
405
+
406
+ # Group results by series/season for organized display
407
+ series_groups = defaultdict(lambda: defaultdict(list))
408
+ total_confidence = 0
409
+
410
+ for result in results:
411
+ series_name = result.episode_info.series_name
412
+ season = result.episode_info.season
413
+ series_groups[series_name][season].append(result)
414
+ total_confidence += result.confidence
415
+
416
+ # Create summary table
417
+ table = Table(title="Episode Matching Summary")
418
+ table.add_column("Original File", style="cyan")
419
+ table.add_column("New Name", style="green")
420
+ table.add_column("Episode", style="magenta", justify="center")
421
+ table.add_column("Confidence", style="yellow", justify="center")
422
+ table.add_column("Status", style="white", justify="center")
423
+
424
+ for series_name in sorted(series_groups.keys()):
425
+ for season in sorted(series_groups[series_name].keys()):
426
+ episodes = series_groups[series_name][season]
427
+
428
+ # Add series header if multiple series
429
+ if len(series_groups) > 1:
430
+ table.add_row(
431
+ f"[bold cyan]{series_name} - Season {season}[/bold cyan]",
432
+ "",
433
+ "",
434
+ "",
435
+ "",
436
+ style="bold cyan",
437
+ )
438
+
439
+ for result in sorted(episodes, key=lambda x: x.episode_info.episode):
440
+ # Use original filename if available, otherwise current filename
441
+ original_name = (
442
+ result.original_file.name
443
+ if result.original_file
444
+ else result.matched_file.name
445
+ )
446
+
447
+ # Generate expected new name
448
+ title_part = (
449
+ f" - {result.episode_info.title}"
450
+ if result.episode_info.title
451
+ else ""
452
+ )
453
+ new_name = f"{result.episode_info.series_name} - {result.episode_info.s_e_format}{title_part}{result.matched_file.suffix}"
454
+
455
+ # Clean the new name for display
456
+ import re
457
+
458
+ new_name = re.sub(r'[<>:"/\\\\|?*]', "", new_name).strip()
459
+
460
+ status = (
461
+ "WOULD RENAME"
462
+ if dry_run
463
+ else (
464
+ "RENAMED"
465
+ if (
466
+ result.original_file
467
+ and result.original_file.name != result.matched_file.name
468
+ )
469
+ else "COPY"
470
+ if output_dir
471
+ else "RENAMED"
472
+ )
473
+ )
474
+
475
+ table.add_row(
476
+ original_name,
477
+ new_name,
478
+ result.episode_info.s_e_format,
479
+ f"{result.confidence:.2f}",
480
+ status,
481
+ )
482
+
483
+ # Add failures to table if any
484
+ if failures:
485
+ for failure in failures:
486
+ table.add_row(
487
+ failure.original_file.name,
488
+ "-",
489
+ "-",
490
+ f"{failure.confidence:.2f}" if failure.confidence > 0 else "-",
491
+ "[red]FAILED[/red]",
492
+ )
493
+
494
+ console.print(table)
495
+
496
+ # Display summary statistics
497
+ avg_confidence = total_confidence / len(results) if results else 0
498
+ console.print("\n[bold]Summary Statistics:[/bold]")
499
+ console.print(f" Total episodes matched: [green]{len(results)}[/green]")
500
+ if failures:
501
+ console.print(f" Total failures: [red]{len(failures)}[/red]")
502
+ console.print(
503
+ f" Average confidence (matches): [yellow]{avg_confidence:.2f}[/yellow]"
504
+ )
505
+ console.print(f" Series processed: [blue]{len(series_groups)}[/blue]")
506
+
507
+ # Season breakdown
508
+ season_count = sum(len(seasons) for seasons in series_groups.values())
509
+ console.print(f" Seasons processed: [magenta]{season_count}[/magenta]")
510
+
511
+ # Display action taken
512
+ console.print("\n[bold]Action Taken:[/bold]")
513
+ if dry_run:
514
+ console.print("[yellow]DRY RUN - No files were actually renamed[/yellow]")
515
+ console.print(
516
+ "Run the command without [bold]--dry-run[/bold] to perform the renames"
517
+ )
518
+ elif output_dir:
519
+ console.print(f"[blue]Files copied to: {output_dir}[/blue]")
520
+ console.print("Original files remain unchanged")
521
+ else:
522
+ console.print("[green]Files renamed in place[/green]")
523
+ console.print("Original filenames have been updated")
524
+
525
+ # Show command to view renamed files
526
+ if not dry_run:
527
+ if output_dir:
528
+ console.print(f'\n[dim]View results: ls "{output_dir}"[/dim]')
529
+ else:
530
+ # Get the parent directory of the first result for the ls command
531
+ if results:
532
+ first_file_dir = results[0].matched_file.parent
533
+ console.print(f'\n[dim]View results: ls "{first_file_dir}"[/dim]')
534
+
535
+ # Warning for failures
536
+ if failures:
537
+ console.print("\n[bold red]Warnings:[/bold red]")
538
+ console.print(
539
+ f"[yellow] • {len(failures)} files could not be matched.[/yellow]"
540
+ )
541
+ console.print(" • Try checking if correct subtitles are available online.")
542
+ console.print(
543
+ " • Consider lowering the confidence threshold with [bold]--confidence[/bold] if matches are close."
544
+ )
545
+
546
+
547
+ @app.command()
548
+ def gui():
549
+ """Launch the GUI application."""
550
+ import flet as ft
551
+
552
+ from mkv_episode_matcher.ui.flet_app import main
553
+
554
+ ft.app(target=main)
555
+
556
+
557
+ if __name__ == "__main__":
558
+ app()
@@ -0,0 +1,100 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from loguru import logger
5
+
6
+ from mkv_episode_matcher.core.models import Config
7
+
8
+
9
+ class ConfigManager:
10
+ """Enhanced configuration manager with JSON-based storage and validation."""
11
+
12
+ def __init__(self, config_path: Path | None = None):
13
+ if config_path is None:
14
+ config_path = Path.home() / ".mkv-episode-matcher" / "config.json"
15
+ self.config_path = config_path
16
+
17
+ def load(self) -> Config:
18
+ """Load configuration from JSON file with fallback to defaults."""
19
+ if not self.config_path.exists():
20
+ logger.info("No config file found, using defaults")
21
+ return self._create_default_config()
22
+
23
+ try:
24
+ data = json.loads(self.config_path.read_text(encoding="utf-8"))
25
+
26
+ # Handle legacy INI config files
27
+ if "Config" in data:
28
+ logger.info("Migrating legacy INI config to JSON")
29
+ data = self._migrate_legacy_config(data)
30
+
31
+ config = Config(**data)
32
+ logger.debug(f"Config loaded from {self.config_path}")
33
+ return config
34
+
35
+ except json.JSONDecodeError as e:
36
+ logger.error(f"Invalid JSON in config file: {e}")
37
+ return self._create_default_config()
38
+ except Exception as e:
39
+ logger.error(f"Failed to load config: {e}")
40
+ return self._create_default_config()
41
+
42
+ def _create_default_config(self) -> Config:
43
+ """Create default configuration."""
44
+ config = Config()
45
+ # Auto-save default config
46
+ self.save(config)
47
+ logger.info(f"Created default configuration at {self.config_path}")
48
+ return config
49
+
50
+ def _migrate_legacy_config(self, legacy_data: dict) -> dict:
51
+ """Migrate legacy INI-style config to new JSON format."""
52
+ legacy_config = legacy_data.get("Config", {})
53
+
54
+ migrated = {
55
+ "tmdb_api_key": legacy_config.get("tmdb_api_key"),
56
+ "show_dir": legacy_config.get("show_dir"),
57
+ "cache_dir": str(Path.home() / ".mkv-episode-matcher" / "cache"),
58
+ "min_confidence": 0.7,
59
+ "asr_provider": "parakeet",
60
+ "sub_provider": "opensubtitles",
61
+ }
62
+
63
+ # Clean up None values
64
+ migrated = {k: v for k, v in migrated.items() if v is not None}
65
+
66
+ logger.info("Legacy config migrated successfully")
67
+ return migrated
68
+
69
+ def save(self, config: Config):
70
+ """Save configuration to JSON file with validation."""
71
+ try:
72
+ # Ensure parent directory exists
73
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
74
+
75
+ # Serialize config to JSON
76
+ config_data = config.model_dump(
77
+ exclude_none=True, # Don't save None values
78
+ by_alias=True, # Use field aliases if defined
79
+ )
80
+
81
+ # Convert Path objects to strings for JSON serialization
82
+ for key, value in config_data.items():
83
+ if isinstance(value, Path):
84
+ config_data[key] = str(value)
85
+
86
+ # Write to file with pretty formatting
87
+ self.config_path.write_text(
88
+ json.dumps(config_data, indent=2, sort_keys=True), encoding="utf-8"
89
+ )
90
+
91
+ logger.info(f"Configuration saved to {self.config_path}")
92
+
93
+ except Exception as e:
94
+ logger.error(f"Failed to save config: {e}")
95
+ raise
96
+
97
+
98
+ def get_config_manager() -> ConfigManager:
99
+ """Get the default configuration manager instance."""
100
+ return ConfigManager()