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,708 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+
4
+ import flet as ft
5
+
6
+ from mkv_episode_matcher import __version__ as version
7
+ from mkv_episode_matcher.core.config_manager import get_config_manager
8
+ from mkv_episode_matcher.core.engine import MatchEngine
9
+
10
+
11
+ async def main(page: ft.Page):
12
+ # -- Theme & Page Setup --
13
+
14
+ page.title = "MKV Episode Matcher"
15
+ page.theme_mode = ft.ThemeMode.DARK
16
+ page.padding = 0
17
+ page.window_width = 900
18
+ page.window_height = 800
19
+ page.window_min_width = 600
20
+ page.window_min_height = 500
21
+
22
+ # Custom Theme
23
+ page.theme = ft.Theme(
24
+ color_scheme_seed=ft.Colors.DEEP_PURPLE,
25
+ visual_density=ft.VisualDensity.COMFORTABLE,
26
+ )
27
+
28
+ # -- State --
29
+ cm = get_config_manager()
30
+ config = cm.load()
31
+ engine = None # Will be initialized after model loads
32
+
33
+ # Model Status UI
34
+ model_status_icon = ft.Icon(ft.Icons.CIRCLE, color=ft.Colors.ORANGE_400, size=12)
35
+ model_status_text = ft.Text("Loading Model...", size=12, color=ft.Colors.GREY_400)
36
+
37
+ # We use a simple boolean to track state for the UI,
38
+ # relying on the background task to update it.
39
+ model_ready = False
40
+
41
+ # Configuration Dialog placeholder (will be defined later)
42
+ open_config_dialog = None
43
+
44
+ # Set up AppBar after model status components are defined
45
+ page.appbar = ft.AppBar(
46
+ leading=ft.Icon(ft.Icons.MOVIE_FILTER),
47
+ leading_width=40,
48
+ title=ft.Text("MKV Episode Matcher"),
49
+ center_title=True,
50
+ bgcolor=ft.Colors.INVERSE_PRIMARY,
51
+ actions=[
52
+ ft.Row(
53
+ [model_status_icon, model_status_text],
54
+ alignment=ft.MainAxisAlignment.CENTER,
55
+ ),
56
+ ft.Container(width=10),
57
+ ft.IconButton(
58
+ icon=ft.Icons.SETTINGS,
59
+ tooltip="Configuration",
60
+ on_click=lambda _: open_config_dialog(_)
61
+ if open_config_dialog
62
+ else None,
63
+ ),
64
+ ft.Container(padding=10, content=ft.Text(f"v{version}")),
65
+ ],
66
+ )
67
+
68
+ def update_model_status(loaded: bool):
69
+ nonlocal model_ready, engine
70
+ model_ready = loaded
71
+ if loaded:
72
+ model_status_icon.name = ft.Icons.CIRCLE
73
+ model_status_icon.color = ft.Colors.GREEN_400
74
+ model_status_text.value = "Model Ready"
75
+ model_status_text.color = ft.Colors.GREEN_400
76
+ # Initialize engine now that model is loaded
77
+ if engine is None:
78
+ engine = MatchEngine(config)
79
+ else:
80
+ model_status_icon.name = ft.Icons.CIRCLE
81
+ model_status_icon.color = ft.Colors.ORANGE_400
82
+ model_status_text.value = "Loading Model..."
83
+ model_status_text.color = ft.Colors.GREY_400
84
+ page.update()
85
+
86
+ # Background loader task
87
+ async def background_load():
88
+ print("Starting background model load...")
89
+ try:
90
+ from mkv_episode_matcher.core.providers.asr import get_asr_provider
91
+
92
+ # Update status to show loading progress
93
+ model_status_text.value = "Downloading model..."
94
+ page.update()
95
+
96
+ # Load ASR model in executor to prevent blocking UI
97
+ loop = asyncio.get_running_loop()
98
+
99
+ # Initialize ASR provider separately
100
+ asr_provider = get_asr_provider(config.asr_provider)
101
+
102
+ model_status_text.value = "Loading model..."
103
+ page.update()
104
+
105
+ await loop.run_in_executor(None, asr_provider.load)
106
+
107
+ print("Background model load complete.")
108
+ update_model_status(True)
109
+ except ImportError as e:
110
+ print(f"Model dependencies not installed: {e}")
111
+ model_status_text.value = "Dependencies Missing"
112
+ model_status_icon.color = ft.Colors.RED_400
113
+ page.update()
114
+ except Exception as e:
115
+ print(f"Background load failed: {e}")
116
+ model_status_text.value = "Model Load Failed"
117
+ model_status_icon.color = ft.Colors.RED_400
118
+ page.update()
119
+
120
+ # Start the background task
121
+ page.run_task(background_load)
122
+
123
+ # -- Components --
124
+
125
+ path_tf = ft.TextField(
126
+ label="Path to Library or MKV Series Folder",
127
+ expand=True,
128
+ border_radius=10,
129
+ prefix_icon=ft.Icons.FOLDER,
130
+ hint_text="Select a folder...",
131
+ )
132
+
133
+ season_tf = ft.TextField(
134
+ label="Season Override",
135
+ width=150,
136
+ keyboard_type=ft.KeyboardType.NUMBER,
137
+ hint_text="Opt. (e.g. 1)",
138
+ border_radius=10,
139
+ prefix_icon=ft.Icons.NUMBERS,
140
+ )
141
+
142
+ # Dry run checkbox
143
+ dry_run_cb = ft.Checkbox(
144
+ label="Dry Run (preview only)",
145
+ value=False,
146
+ tooltip="Preview matches without renaming files",
147
+ )
148
+
149
+ # List View for Results
150
+ results_lv = ft.ListView(expand=True, spacing=5, padding=ft.Padding.all(15))
151
+
152
+ # Progress indicators
153
+ progress_bar = ft.ProgressBar(visible=False, width=400, height=8)
154
+ progress_ring = ft.ProgressRing(visible=False, width=20, height=20)
155
+ status_text = ft.Text("Ready", italic=True, color=ft.Colors.GREY_400)
156
+ progress_text = ft.Text("", size=12, color=ft.Colors.GREY_500, visible=False)
157
+
158
+ # -- File Picker --
159
+ async def pick_click(_):
160
+ result = await ft.FilePicker().get_directory_path(
161
+ dialog_title="Select MKV file or Series Folder"
162
+ )
163
+ if result:
164
+ path_tf.value = result
165
+ path_tf.update()
166
+ show_snack(f"Selected: {Path(result).name}", ft.Colors.GREEN)
167
+
168
+ def show_snack(message: str, color=ft.Colors.GREY_700):
169
+ # Use overlay to show snackbar
170
+ snack = ft.SnackBar(ft.Text(message), bgcolor=color)
171
+ page.overlay.append(snack)
172
+ snack.open = True
173
+ page.update()
174
+
175
+ # -- Configuration Dialog --
176
+ def open_config_dialog_impl(_):
177
+ # Config form fields
178
+ cache_dir_tf = ft.TextField(
179
+ label="Cache Directory",
180
+ value=str(config.cache_dir),
181
+ expand=True,
182
+ hint_text="Directory for storing cache and downloaded subtitles",
183
+ )
184
+
185
+ confidence_tf = ft.TextField(
186
+ label="Minimum Confidence Threshold",
187
+ value=str(config.min_confidence),
188
+ keyboard_type=ft.KeyboardType.NUMBER,
189
+ hint_text="0.0 to 1.0 (e.g., 0.7)",
190
+ )
191
+
192
+ asr_provider_dd = ft.Dropdown(
193
+ label="ASR Provider",
194
+ value=config.asr_provider,
195
+ options=[
196
+ ft.dropdown.Option("parakeet", "Parakeet (NVIDIA NeMo)"),
197
+ ],
198
+ hint_text="Speech recognition model",
199
+ )
200
+
201
+ subtitle_provider_dd = ft.Dropdown(
202
+ label="Subtitle Provider",
203
+ value=config.sub_provider,
204
+ options=[
205
+ ft.dropdown.Option("local", "Local Only"),
206
+ ft.dropdown.Option("opensubtitles", "Local + OpenSubtitles"),
207
+ ],
208
+ hint_text="Where to get reference subtitles",
209
+ )
210
+
211
+ # OpenSubtitles fields
212
+ os_api_key_tf = ft.TextField(
213
+ label="OpenSubtitles API Key",
214
+ value=config.open_subtitles_api_key or "",
215
+ hint_text="Required for OpenSubtitles downloads",
216
+ password=True,
217
+ )
218
+
219
+ os_username_tf = ft.TextField(
220
+ label="OpenSubtitles Username",
221
+ value=config.open_subtitles_username or "",
222
+ hint_text="Optional but recommended",
223
+ )
224
+
225
+ os_password_tf = ft.TextField(
226
+ label="OpenSubtitles Password",
227
+ value=config.open_subtitles_password or "",
228
+ password=True,
229
+ hint_text="Optional but recommended",
230
+ )
231
+
232
+ # TMDb field
233
+ tmdb_api_key_tf = ft.TextField(
234
+ label="TMDb API Key",
235
+ value=config.tmdb_api_key or "",
236
+ hint_text="Optional - for episode titles and metadata",
237
+ password=True,
238
+ )
239
+
240
+ def save_config(_):
241
+ nonlocal config
242
+ try:
243
+ # Update config object
244
+ config.cache_dir = Path(cache_dir_tf.value)
245
+ config.min_confidence = float(confidence_tf.value)
246
+ config.asr_provider = asr_provider_dd.value
247
+ config.sub_provider = subtitle_provider_dd.value
248
+ config.open_subtitles_api_key = os_api_key_tf.value or None
249
+ config.open_subtitles_username = os_username_tf.value or None
250
+ config.open_subtitles_password = os_password_tf.value or None
251
+ config.tmdb_api_key = tmdb_api_key_tf.value or None
252
+
253
+ # Save to file
254
+ cm.save(config)
255
+
256
+ # Close dialog and show success
257
+ config_dialog.open = False
258
+ page.update()
259
+ show_snack("Configuration saved successfully!", ft.Colors.GREEN)
260
+
261
+ # Reset engine to use new config (model will reload if needed)
262
+ nonlocal engine, model_ready
263
+ engine = None
264
+ model_ready = False
265
+ update_model_status(False)
266
+ page.run_task(background_load)
267
+
268
+ except ValueError as e:
269
+ show_snack(f"Invalid value: {str(e)}", ft.Colors.RED_400)
270
+ except Exception as e:
271
+ show_snack(f"Save failed: {str(e)}", ft.Colors.RED_400)
272
+
273
+ def cancel_config(_):
274
+ config_dialog.open = False
275
+ page.update()
276
+
277
+ config_dialog = ft.AlertDialog(
278
+ modal=True,
279
+ title=ft.Text("Configuration"),
280
+ content=ft.Container(
281
+ content=ft.Column(
282
+ [
283
+ ft.Text("General Settings", weight=ft.FontWeight.BOLD),
284
+ cache_dir_tf,
285
+ confidence_tf,
286
+ asr_provider_dd,
287
+ subtitle_provider_dd,
288
+ ft.Divider(),
289
+ ft.Text("OpenSubtitles Settings", weight=ft.FontWeight.BOLD),
290
+ os_api_key_tf,
291
+ os_username_tf,
292
+ os_password_tf,
293
+ ft.Divider(),
294
+ ft.Text("Optional Services", weight=ft.FontWeight.BOLD),
295
+ tmdb_api_key_tf,
296
+ ],
297
+ spacing=10,
298
+ scroll=ft.ScrollMode.AUTO,
299
+ ),
300
+ width=500,
301
+ height=600,
302
+ ),
303
+ actions=[
304
+ ft.TextButton("Cancel", on_click=cancel_config),
305
+ ft.FilledButton("Save", on_click=save_config),
306
+ ],
307
+ actions_alignment=ft.MainAxisAlignment.END,
308
+ )
309
+
310
+ page.overlay.append(config_dialog)
311
+ config_dialog.open = True
312
+ page.update()
313
+
314
+ # Assign the function to the variable for the AppBar
315
+ open_config_dialog = open_config_dialog_impl
316
+
317
+ async def start_process(_):
318
+ if not path_tf.value:
319
+ show_snack("Please select a path", ft.Colors.RED_400)
320
+ return
321
+
322
+ if not model_ready or engine is None:
323
+ show_snack("Please wait for model to load...", ft.Colors.ORANGE_400)
324
+ return
325
+
326
+ path = Path(path_tf.value)
327
+ if not path.exists():
328
+ show_snack("Path does not exist", ft.Colors.RED_400)
329
+ return
330
+
331
+ # UI Reset
332
+ results_lv.controls.clear()
333
+ progress_ring.visible = True
334
+ progress_bar.visible = True
335
+ progress_text.visible = True
336
+ status_text.value = "Starting processing..."
337
+ progress_text.value = "Initializing..."
338
+ progress_bar.value = 0
339
+ page.update()
340
+
341
+ # Run processing in executor with progress updates
342
+ loop = asyncio.get_running_loop()
343
+
344
+ season_val = None
345
+ if season_tf.value and season_tf.value.isdigit():
346
+ season_val = int(season_tf.value)
347
+
348
+ # Progress callback function - more responsive updates
349
+ def update_progress(current, total):
350
+ if total > 0:
351
+ progress_value = current / total
352
+ progress_bar.value = progress_value
353
+ progress_text.value = f"Processing file {current} of {total}"
354
+ status_text.value = f"Processing files... ({current}/{total})"
355
+
356
+ # Multiple update attempts for better responsiveness
357
+ try:
358
+ page.update()
359
+ # Also try updating individual controls
360
+ progress_bar.update()
361
+ progress_text.update()
362
+ status_text.update()
363
+ except Exception:
364
+ pass # Ignore any update errors from background thread
365
+
366
+ try:
367
+ # Check dry run mode
368
+ is_dry_run = dry_run_cb.value
369
+
370
+ # Add timeout to prevent indefinite hang
371
+ tup = await asyncio.wait_for(
372
+ loop.run_in_executor(
373
+ None,
374
+ lambda: engine.process_path(
375
+ path,
376
+ season_override=season_val,
377
+ dry_run=is_dry_run,
378
+ progress_callback=update_progress,
379
+ ),
380
+ ),
381
+ timeout=300.0, # 5 minutes timeout
382
+ )
383
+
384
+ # Unpack safely
385
+ if isinstance(tup, tuple) and len(tup) == 2:
386
+ results, failures = tup
387
+ else:
388
+ results = []
389
+ failures = tup if isinstance(tup, list) else []
390
+
391
+ # Hide progress indicators and show results
392
+ progress_ring.visible = False
393
+ progress_bar.visible = False
394
+ progress_text.visible = False
395
+ dry_run_text = " (Dry Run)" if is_dry_run else ""
396
+ status_text.value = f"Complete{dry_run_text}: {len(results)} matches, {len(failures)} failures"
397
+
398
+ if not results and not failures:
399
+ results_lv.controls.append(
400
+ ft.Container(
401
+ content=ft.Column(
402
+ [
403
+ ft.Icon(
404
+ ft.Icons.SEARCH_OFF,
405
+ size=50,
406
+ color=ft.Colors.GREY_700,
407
+ ),
408
+ ft.Text(
409
+ "No compatible files found to process.",
410
+ color=ft.Colors.GREY_500,
411
+ ),
412
+ ],
413
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
414
+ ),
415
+ alignment=ft.Alignment.CENTER,
416
+ padding=40,
417
+ )
418
+ )
419
+
420
+ # Failures Section
421
+ if failures:
422
+ results_lv.controls.append(
423
+ ft.Text(
424
+ "Failures / Ignored",
425
+ weight=ft.FontWeight.BOLD,
426
+ color=ft.Colors.RED_200,
427
+ )
428
+ )
429
+ for fail in failures:
430
+ card = ft.Card(
431
+ elevation=1,
432
+ content=ft.Container(
433
+ content=ft.Row([
434
+ ft.Icon(
435
+ ft.Icons.ERROR_OUTLINE,
436
+ color=ft.Colors.RED_400,
437
+ size=16,
438
+ ),
439
+ ft.Container(width=8),
440
+ ft.Container(
441
+ content=ft.Column(
442
+ [
443
+ ft.Row([
444
+ ft.Text(
445
+ fail.original_file.name,
446
+ weight=ft.FontWeight.W_500,
447
+ size=13,
448
+ overflow=ft.TextOverflow.ELLIPSIS,
449
+ expand=True,
450
+ ),
451
+ ft.Text(
452
+ f"{fail.series_name} S{fail.season:02d}"
453
+ if fail.series_name and fail.season
454
+ else "",
455
+ color=ft.Colors.RED_200,
456
+ size=11,
457
+ weight=ft.FontWeight.W_600,
458
+ )
459
+ if fail.series_name and fail.season
460
+ else ft.Container(),
461
+ ]),
462
+ ft.Text(
463
+ fail.reason,
464
+ color=ft.Colors.RED_300,
465
+ size=11,
466
+ overflow=ft.TextOverflow.ELLIPSIS,
467
+ ),
468
+ ],
469
+ spacing=2,
470
+ ),
471
+ expand=True,
472
+ ),
473
+ ]),
474
+ padding=ft.Padding.all(12),
475
+ bgcolor=ft.Colors.RED_900
476
+ if page.theme_mode == ft.ThemeMode.DARK
477
+ else ft.Colors.RED_50,
478
+ ),
479
+ )
480
+ results_lv.controls.append(card)
481
+
482
+ # Successes Section
483
+ if results:
484
+ if failures:
485
+ results_lv.controls.append(ft.Divider())
486
+ results_lv.controls.append(
487
+ ft.Text(
488
+ "Matches", weight=ft.FontWeight.BOLD, color=ft.Colors.GREEN_200
489
+ )
490
+ )
491
+
492
+ for res in results:
493
+
494
+ def rename_click(e, r=res):
495
+ if is_dry_run:
496
+ # Preview mode - just show what the new name would be
497
+ try:
498
+ title_part = (
499
+ f" - {r.episode_info.title}"
500
+ if r.episode_info.title
501
+ else ""
502
+ )
503
+ new_filename = f"{r.episode_info.series_name} - {r.episode_info.s_e_format}{title_part}{r.matched_file.suffix}"
504
+ # Clean filename
505
+ import re
506
+
507
+ new_filename = re.sub(
508
+ r'[<>:"/\\\\|?*]', "", new_filename
509
+ ).strip()
510
+ show_snack(
511
+ f"Would rename to: {new_filename}", ft.Colors.BLUE
512
+ )
513
+ except Exception as ex:
514
+ show_snack(f"Preview error: {ex}", ft.Colors.RED)
515
+ else:
516
+ # Actual rename mode
517
+ try:
518
+ new_path = engine._perform_rename(r)
519
+ if new_path:
520
+ e.control.text = "Renamed"
521
+ e.control.disabled = True
522
+ e.control.icon = ft.Icons.CHECK
523
+ show_snack(
524
+ f"Renamed to {new_path.name}", ft.Colors.GREEN
525
+ )
526
+ e.control.update()
527
+ else:
528
+ show_snack("Rename failed", ft.Colors.RED)
529
+ except Exception as ex:
530
+ show_snack(f"Rename error: {ex}", ft.Colors.RED)
531
+
532
+ # Determine button text and behavior based on dry run mode
533
+ button_text = "Preview Rename" if is_dry_run else "Rename"
534
+ button_icon = (
535
+ ft.Icons.PREVIEW
536
+ if is_dry_run
537
+ else ft.Icons.DRIVE_FILE_RENAME_OUTLINE
538
+ )
539
+
540
+ # Compact card design
541
+ card = ft.Card(
542
+ elevation=2,
543
+ content=ft.Container(
544
+ content=ft.Row([
545
+ # File info section
546
+ ft.Container(
547
+ content=ft.Column(
548
+ [
549
+ ft.Row([
550
+ ft.Icon(
551
+ ft.Icons.MOVIE,
552
+ color=ft.Colors.INDIGO_400,
553
+ size=16,
554
+ ),
555
+ ft.Container(width=5),
556
+ ft.Container(
557
+ content=ft.Text(
558
+ res.matched_file.name,
559
+ weight=ft.FontWeight.W_500,
560
+ size=13,
561
+ overflow=ft.TextOverflow.ELLIPSIS,
562
+ ),
563
+ expand=True,
564
+ ),
565
+ ]),
566
+ ft.Row([
567
+ ft.Text(
568
+ f"→ {res.episode_info.s_e_format}",
569
+ color=ft.Colors.GREEN_400,
570
+ weight=ft.FontWeight.BOLD,
571
+ size=12,
572
+ ),
573
+ ft.Container(width=15),
574
+ ft.Text(
575
+ f"{res.confidence:.0%}",
576
+ size=11,
577
+ color=ft.Colors.GREEN_400
578
+ if res.confidence > 0.8
579
+ else ft.Colors.ORANGE_400,
580
+ ),
581
+ ]),
582
+ ],
583
+ spacing=2,
584
+ ),
585
+ expand=True,
586
+ ),
587
+ # Button section
588
+ ft.Container(
589
+ content=ft.ElevatedButton(
590
+ button_text,
591
+ icon=button_icon,
592
+ on_click=rename_click,
593
+ style=ft.ButtonStyle(
594
+ text_style=ft.TextStyle(size=12),
595
+ padding=ft.Padding.symmetric(
596
+ horizontal=12, vertical=8
597
+ ),
598
+ ),
599
+ ),
600
+ alignment=ft.Alignment.CENTER,
601
+ ),
602
+ ]),
603
+ padding=ft.Padding.all(12),
604
+ ),
605
+ )
606
+ results_lv.controls.append(card)
607
+
608
+ except asyncio.TimeoutError:
609
+ progress_ring.visible = False
610
+ progress_bar.visible = False
611
+ progress_text.visible = False
612
+ status_text.value = "Processing timed out (5 minutes)"
613
+ show_snack(
614
+ "Processing timed out - try processing fewer files at once",
615
+ ft.Colors.ORANGE_400,
616
+ )
617
+ except FileNotFoundError as e:
618
+ progress_ring.visible = False
619
+ progress_bar.visible = False
620
+ progress_text.visible = False
621
+ status_text.value = "File not found"
622
+ show_snack(f"File not found: {str(e)}", ft.Colors.RED_400)
623
+ except PermissionError as e:
624
+ progress_ring.visible = False
625
+ progress_bar.visible = False
626
+ progress_text.visible = False
627
+ status_text.value = "Permission denied"
628
+ show_snack(f"Permission error: {str(e)}", ft.Colors.RED_400)
629
+ except Exception as e:
630
+ progress_ring.visible = False
631
+ progress_bar.visible = False
632
+ progress_text.visible = False
633
+ status_text.value = f"Error: {str(e)}"
634
+ show_snack(f"Processing error: {str(e)}", ft.Colors.RED_400)
635
+ import traceback
636
+
637
+ traceback.print_exc()
638
+
639
+ page.update()
640
+
641
+ # -- Main Layout --
642
+ page.add(
643
+ ft.Container(
644
+ content=ft.Column([
645
+ ft.Container(
646
+ content=ft.Column([
647
+ ft.Text(
648
+ "Select Media Source", size=16, weight=ft.FontWeight.W_600
649
+ ),
650
+ ft.Row(
651
+ controls=[
652
+ path_tf,
653
+ ft.IconButton(
654
+ icon=ft.Icons.FOLDER_OPEN,
655
+ tooltip="Browse Folder",
656
+ icon_size=30,
657
+ on_click=pick_click,
658
+ icon_color=ft.Colors.INDIGO_300,
659
+ ),
660
+ ],
661
+ vertical_alignment=ft.CrossAxisAlignment.START,
662
+ ),
663
+ ft.Row([
664
+ season_tf,
665
+ ft.Container(width=15),
666
+ dry_run_cb,
667
+ ]),
668
+ ft.Container(height=10),
669
+ ft.FilledButton(
670
+ "Start Processing",
671
+ icon=ft.Icons.PLAY_ARROW_ROUNDED,
672
+ on_click=start_process,
673
+ style=ft.ButtonStyle(
674
+ padding=20,
675
+ shape=ft.RoundedRectangleBorder(radius=10),
676
+ ),
677
+ height=50,
678
+ expand=True,
679
+ ),
680
+ ft.Divider(height=30, thickness=1, color=ft.Colors.GREY_800),
681
+ ft.Column(
682
+ [
683
+ ft.Row([
684
+ progress_ring,
685
+ ft.Container(width=10),
686
+ status_text,
687
+ ]),
688
+ progress_bar,
689
+ progress_text,
690
+ ],
691
+ spacing=5,
692
+ ),
693
+ ]),
694
+ padding=20,
695
+ bgcolor=ft.Colors.GREY_900,
696
+ border_radius=10,
697
+ ),
698
+ ft.Container(height=10),
699
+ results_lv,
700
+ ]),
701
+ padding=20,
702
+ expand=True,
703
+ )
704
+ )
705
+
706
+
707
+ if __name__ == "__main__":
708
+ ft.run(main=main)