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,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)
|