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.
- mkv_episode_matcher/__init__.py +8 -0
- mkv_episode_matcher/__main__.py +2 -177
- mkv_episode_matcher/asr_models.py +506 -0
- mkv_episode_matcher/cli.py +558 -0
- mkv_episode_matcher/core/config_manager.py +100 -0
- mkv_episode_matcher/core/engine.py +577 -0
- mkv_episode_matcher/core/matcher.py +214 -0
- mkv_episode_matcher/core/models.py +91 -0
- mkv_episode_matcher/core/providers/asr.py +85 -0
- mkv_episode_matcher/core/providers/subtitles.py +341 -0
- mkv_episode_matcher/core/utils.py +148 -0
- mkv_episode_matcher/episode_identification.py +550 -118
- mkv_episode_matcher/subtitle_utils.py +82 -0
- mkv_episode_matcher/tmdb_client.py +56 -14
- mkv_episode_matcher/ui/flet_app.py +708 -0
- mkv_episode_matcher/utils.py +262 -139
- mkv_episode_matcher-1.0.0.dist-info/METADATA +242 -0
- mkv_episode_matcher-1.0.0.dist-info/RECORD +23 -0
- {mkv_episode_matcher-0.3.3.dist-info → mkv_episode_matcher-1.0.0.dist-info}/WHEEL +1 -1
- mkv_episode_matcher-1.0.0.dist-info/licenses/LICENSE +21 -0
- mkv_episode_matcher/config.py +0 -82
- mkv_episode_matcher/episode_matcher.py +0 -100
- mkv_episode_matcher/libraries/pgs2srt/.gitignore +0 -2
- mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/SubZero.py +0 -321
- mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/dictionaries/data.py +0 -16700
- mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/post_processing.py +0 -260
- mkv_episode_matcher/libraries/pgs2srt/README.md +0 -26
- mkv_episode_matcher/libraries/pgs2srt/__init__.py +0 -0
- mkv_episode_matcher/libraries/pgs2srt/imagemaker.py +0 -89
- mkv_episode_matcher/libraries/pgs2srt/pgs2srt.py +0 -150
- mkv_episode_matcher/libraries/pgs2srt/pgsreader.py +0 -225
- mkv_episode_matcher/libraries/pgs2srt/requirements.txt +0 -4
- mkv_episode_matcher/mkv_to_srt.py +0 -302
- mkv_episode_matcher/speech_to_text.py +0 -90
- mkv_episode_matcher-0.3.3.dist-info/METADATA +0 -125
- mkv_episode_matcher-0.3.3.dist-info/RECORD +0 -25
- {mkv_episode_matcher-0.3.3.dist-info → mkv_episode_matcher-1.0.0.dist-info}/entry_points.txt +0 -0
- {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()
|