plex-generate-previews 2.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.
@@ -0,0 +1,10 @@
1
+ """
2
+ Plex Video Preview Generator
3
+
4
+ A tool for generating video preview thumbnails for Plex Media Server.
5
+ Supports GPU acceleration (NVIDIA, AMD, Intel, WSL2) and CPU processing.
6
+ """
7
+
8
+ __version__ = "2.0.0"
9
+ __author__ = "stevezau"
10
+ __description__ = "Generate video preview thumbnails for Plex Media Server"
@@ -0,0 +1,11 @@
1
+ """
2
+ Entry point for python -m plex_generate_previews
3
+
4
+ Allows running the package as a module:
5
+ python -m plex_generate_previews
6
+ """
7
+
8
+ from .cli import main
9
+
10
+ if __name__ == '__main__':
11
+ main()
@@ -0,0 +1,474 @@
1
+ """
2
+ Command-line interface for Plex Video Preview Generator.
3
+
4
+ Main entry point that orchestrates all components: configuration,
5
+ GPU detection, Plex connection, and worker pool management.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import shutil
11
+ import signal
12
+ import argparse
13
+ from loguru import logger
14
+ from rich.console import Console
15
+ from rich.progress import Progress, SpinnerColumn, MofNCompleteColumn, ProgressColumn, BarColumn, TextColumn, TimeElapsedColumn
16
+ from rich.live import Live
17
+ from rich.console import Group
18
+ from rich.text import Text
19
+
20
+ from .config import Config, load_config
21
+ from .gpu_detection import detect_all_gpus, format_gpu_info
22
+ from .plex_client import plex_server, get_library_sections
23
+ from .worker import WorkerPool
24
+ from .utils import calculate_title_width, setup_working_directory as create_working_directory
25
+ from .version_check import check_for_updates
26
+
27
+ # Shared console for coordinated logging and progress output
28
+ console = Console()
29
+
30
+
31
+ class ApplicationState:
32
+ """Global application state for signal handling and cleanup."""
33
+ def __init__(self):
34
+ self.config = None
35
+ self.console = console
36
+
37
+ def set_config(self, config):
38
+ """Set the configuration object."""
39
+ self.config = config
40
+
41
+ def cleanup(self):
42
+ """Perform cleanup operations."""
43
+ # Restore terminal cursor visibility
44
+ if self.console:
45
+ self.console.show_cursor(True)
46
+
47
+ # Clean up working tmp folder if it exists
48
+ try:
49
+ if self.config and hasattr(self.config, 'working_tmp_folder'):
50
+ if os.path.isdir(self.config.working_tmp_folder):
51
+ shutil.rmtree(self.config.working_tmp_folder)
52
+ logger.debug(f"Cleaned up working temp folder: {self.config.working_tmp_folder}")
53
+ except Exception as cleanup_error:
54
+ logger.warning(f"Failed to clean up working temp folder during interrupt: {cleanup_error}")
55
+
56
+
57
+ # Global application state
58
+ app_state = ApplicationState()
59
+
60
+
61
+ class AnimatedBarColumn(BarColumn):
62
+ """Custom animated progress bar with scrolling red bars."""
63
+
64
+ def __init__(self, bar_width=None, style="green", complete_style="red", finished_style="green"):
65
+ super().__init__(bar_width=bar_width, style=style)
66
+ self.complete_style = complete_style
67
+ self.finished_style = finished_style
68
+ self._animation_offset = 0
69
+
70
+ def render(self, task):
71
+ """Render the animated progress bar."""
72
+ if task.total is None or task.total == 0:
73
+ return Text("", style=self.style)
74
+
75
+ # Calculate progress
76
+ progress = task.completed / task.total
77
+ completed_width = int(progress * (self.bar_width or 40))
78
+
79
+ # Create the base bar
80
+ bar_text = "█" * completed_width + "░" * ((self.bar_width or 40) - completed_width)
81
+
82
+ # Add animated red bars for incomplete portion
83
+ if completed_width < (self.bar_width or 40):
84
+ # Create scrolling red bars effect
85
+ remaining_width = (self.bar_width or 40) - completed_width
86
+ red_bars = "█" * min(3, remaining_width) # 3-character red bars
87
+
88
+ # Animate the red bars position
89
+ self._animation_offset = (self._animation_offset + 1) % max(1, remaining_width - 2)
90
+
91
+ # Insert red bars at animated position
92
+ if remaining_width > 3:
93
+ bar_list = list(bar_text)
94
+ start_pos = completed_width + self._animation_offset
95
+ for i, char in enumerate(red_bars):
96
+ pos = start_pos + i
97
+ if pos < len(bar_list) and bar_list[pos] == "░":
98
+ bar_list[pos] = char
99
+ bar_text = "".join(bar_list)
100
+
101
+ # Apply styling
102
+ if task.finished:
103
+ style = self.finished_style
104
+ else:
105
+ style = self.complete_style if completed_width > 0 else self.style
106
+
107
+ return Text(bar_text, style=style)
108
+
109
+
110
+ class FFmpegDataColumn(ProgressColumn):
111
+ """Custom column to display FFmpeg data for worker progress bars."""
112
+
113
+ def render(self, task):
114
+ # Get FFmpeg data from task fields
115
+ frame = task.fields.get("frame", 0)
116
+ fps = task.fields.get("fps", 0)
117
+ time_str = task.fields.get("time_str", "00:00:00.00")
118
+ speed = task.fields.get("speed", "0.0x")
119
+
120
+ # Create simplified FFmpeg-style output with only essential info
121
+ if frame > 0 or fps > 0:
122
+ ffmpeg_data = f"frame={frame:4d} fps={fps:4.1f} time={time_str} speed={speed}"
123
+ return Text(ffmpeg_data, style="dim")
124
+ else:
125
+ return Text("Waiting for FFmpeg data...", style="dim")
126
+
127
+
128
+ def parse_arguments() -> argparse.Namespace:
129
+ """Parse command-line arguments."""
130
+ parser = argparse.ArgumentParser(
131
+ description='Generate video preview thumbnails for Plex Media Server'
132
+ )
133
+
134
+ # Plex server configuration
135
+ parser.add_argument('--plex-url', help='Plex server URL (e.g., http://localhost:32400)')
136
+ parser.add_argument('--plex-token', help='Plex authentication token (get from https://support.plex.tv/articles/204059436/)')
137
+ parser.add_argument('--plex-timeout', type=int, help='Plex API timeout in seconds (default: 60)')
138
+ parser.add_argument('--plex-libraries', help='Comma-separated list of library names (e.g., "Movies, TV Shows")')
139
+
140
+ # Media paths
141
+ parser.add_argument('--plex-config-folder', help='Path to Plex Media Server configuration folder (e.g., /path_to/plex/Library/Application Support/Plex Media Server)')
142
+ parser.add_argument('--plex-local-videos-path-mapping', help='Local videos path mapping (e.g., /path/this/script/sees/to/video/library)')
143
+ parser.add_argument('--plex-videos-path-mapping', help='Plex videos path mapping (e.g., /path/plex/sees/to/video/library)')
144
+
145
+ # Processing configuration
146
+ parser.add_argument('--plex-bif-frame-interval', type=int, help='Interval between preview images in seconds (default: 5)')
147
+ parser.add_argument('--thumbnail-quality', type=int, help='Preview image quality 1-10 (default: 4, 2=highest quality, 10=lowest quality)')
148
+ parser.add_argument('--regenerate-thumbnails', action='store_true', help='Regenerate existing thumbnails (default: false)')
149
+
150
+ # Threading configuration
151
+ parser.add_argument('--gpu-threads', type=int, help='Number of GPU worker threads (default: 4)')
152
+ parser.add_argument('--cpu-threads', type=int, help='Number of CPU worker threads (default: 4)')
153
+ parser.add_argument('--gpu-selection', help='GPU selection: "all" or comma-separated indices like "0,1,2" (default: all)')
154
+ parser.add_argument('--list-gpus', action='store_true', help='List detected GPUs and exit')
155
+
156
+ # System paths
157
+ parser.add_argument('--tmp-folder', help='Temporary folder for processing (default: /tmp/plex_generate_previews)')
158
+
159
+ # Logging
160
+ parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'debug', 'info', 'warning', 'error'], help='Logging level (default: INFO)')
161
+
162
+ # Version check
163
+ parser.add_argument('--skip-version-check', action='store_true', help='Skip checking for newer versions on startup')
164
+
165
+ return parser.parse_args()
166
+
167
+
168
+ def setup_logging(log_level: str = 'INFO') -> None:
169
+ """Set up logging configuration with shared Rich console."""
170
+ logger.remove()
171
+ logger.add(
172
+ lambda msg: console.print(msg, end=''),
173
+ level=log_level,
174
+ format='<green>{time:YYYY/MM/DD HH:mm:ss}</green> | {level.icon} - <level>{message}</level>',
175
+ enqueue=True
176
+ )
177
+
178
+
179
+ def signal_handler(signum, frame):
180
+ """Handle interrupt signals gracefully."""
181
+ logger.info("Received interrupt signal, shutting down gracefully...")
182
+
183
+ # Perform cleanup using global application state
184
+ app_state.cleanup()
185
+
186
+ sys.exit(0)
187
+
188
+
189
+ def list_gpus() -> None:
190
+ """List detected GPUs and exit."""
191
+ logger.info('🔍 Detecting available GPUs...')
192
+
193
+ detected_gpus = detect_all_gpus()
194
+
195
+ if not detected_gpus:
196
+ logger.info('❌ No GPUs detected')
197
+ logger.info('💡 Use --cpu-threads to run with CPU-only processing')
198
+ return
199
+
200
+ logger.info(f'✅ Found {len(detected_gpus)} GPU(s):')
201
+ for i, (gpu_type, gpu_device, gpu_info) in enumerate(detected_gpus):
202
+ gpu_name = gpu_info.get('name', f'{gpu_type} GPU')
203
+ gpu_desc = format_gpu_info(gpu_type, gpu_device, gpu_name)
204
+ logger.info(f' [{i}] {gpu_desc}')
205
+
206
+ logger.info('')
207
+ logger.info('💡 Use --gpu-selection "0,1" to select specific GPUs')
208
+ logger.info('💡 Use --gpu-selection "all" to use all detected GPUs (default)')
209
+
210
+
211
+ def setup_application() -> tuple:
212
+ """Set up logging, parse arguments, and handle special flags."""
213
+ # Set up logging with default level first
214
+ setup_logging()
215
+
216
+ # Parse command-line arguments
217
+ args = parse_arguments()
218
+
219
+ # Handle --list-gpus flag
220
+ if args.list_gpus:
221
+ list_gpus()
222
+ return None, None
223
+
224
+ logger.info('This project has been completely rewritten for better performance and reliability.')
225
+ logger.info('Please report any issues at https://github.com/stevezau/plex_generate_vid_previews/issues')
226
+
227
+ # Check for updates (non-blocking, fails gracefully)
228
+ check_for_updates(skip_check=args.skip_version_check)
229
+
230
+ # Set up signal handlers for graceful shutdown
231
+ signal.signal(signal.SIGINT, signal_handler)
232
+ signal.signal(signal.SIGTERM, signal_handler)
233
+
234
+ # Load and validate configuration (CLI args take precedence over env vars)
235
+ # Note: Basic logging is already set up, so config validation errors will be logged properly
236
+ config = load_config(args)
237
+
238
+ # Exit if configuration validation failed
239
+ if config is None:
240
+ sys.exit(1)
241
+
242
+ # Store config in global application state for cleanup
243
+ app_state.set_config(config)
244
+
245
+ # Update logging level from config (in case it wasn't set in load_config)
246
+ setup_logging(config.log_level)
247
+
248
+ return args, config
249
+
250
+
251
+ def setup_working_directory(config) -> None:
252
+ """Create and set up the working temporary directory."""
253
+ try:
254
+ config.working_tmp_folder = create_working_directory(config.tmp_folder)
255
+ logger.debug(f"Created working temp folder: {config.working_tmp_folder}")
256
+ except Exception as cleanup_error:
257
+ logger.error(f"Failed to create working temp folder: {cleanup_error}")
258
+ sys.exit(1)
259
+
260
+
261
+ def detect_and_select_gpus(config) -> list:
262
+ """Detect available GPUs and select based on configuration."""
263
+ selected_gpus = []
264
+ if config.gpu_threads > 0:
265
+ # Detect all available GPUs
266
+ detected_gpus = detect_all_gpus()
267
+
268
+ if not detected_gpus:
269
+ logger.error('No GPUs detected.')
270
+ logger.error('Please set the GPU_THREADS environment variable to 0 to use CPU-only processing.')
271
+ logger.error('If you think this is an error please log an issue here https://github.com/stevezau/plex_generate_vid_previews/issues')
272
+ sys.exit(1)
273
+
274
+ # Display detected GPUs
275
+ logger.info(f'🔍 Detected {len(detected_gpus)} GPU(s):')
276
+ for i, (gpu_type, gpu_device, gpu_info) in enumerate(detected_gpus):
277
+ gpu_name = gpu_info.get('name', f'{gpu_type} GPU')
278
+ gpu_desc = format_gpu_info(gpu_type, gpu_device, gpu_name)
279
+ logger.info(f' [{i}] {gpu_desc}')
280
+
281
+ # Filter GPUs based on selection
282
+ if config.gpu_selection.lower() == 'all':
283
+ selected_gpus = detected_gpus
284
+ logger.info(f'✅ Using all {len(selected_gpus)} GPU(s)')
285
+ if len(detected_gpus) > 1:
286
+ logger.info(f'💡 To use specific GPUs only, use --gpu-selection "0" or --gpu-selection "0,1"')
287
+ else:
288
+ try:
289
+ # Parse GPU indices
290
+ gpu_indices = [int(x.strip()) for x in config.gpu_selection.split(',') if x.strip()]
291
+ selected_gpus = []
292
+
293
+ for idx in gpu_indices:
294
+ if 0 <= idx < len(detected_gpus):
295
+ selected_gpus.append(detected_gpus[idx])
296
+ else:
297
+ logger.error(f'❌ GPU {idx} not found. Available GPUs: 0-{len(detected_gpus)-1}')
298
+ logger.error('💡 Run with --list-gpus to see available GPUs')
299
+ sys.exit(1)
300
+
301
+ if not selected_gpus:
302
+ logger.error('❌ No valid GPUs selected')
303
+ sys.exit(1)
304
+
305
+ logger.info(f'✅ Using {len(selected_gpus)} selected GPU(s): {config.gpu_selection}')
306
+
307
+ except ValueError:
308
+ logger.error(f'❌ Invalid GPU selection format: {config.gpu_selection}')
309
+ logger.error('💡 Use "all" or comma-separated indices like "0,1,2"')
310
+ sys.exit(1)
311
+ else:
312
+ logger.debug("GPU threads set to 0 - using CPU-only processing")
313
+
314
+ return selected_gpus
315
+
316
+
317
+ def create_progress_displays():
318
+ """Create progress display instances for different purposes."""
319
+ # Create separate Progress instances for different purposes
320
+ main_progress = Progress(
321
+ SpinnerColumn(),
322
+ TextColumn("[bold green]{task.description}"),
323
+ BarColumn(bar_width=None, style="green"),
324
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
325
+ MofNCompleteColumn(),
326
+ TimeElapsedColumn(),
327
+ console=console,
328
+ refresh_per_second=20
329
+ )
330
+
331
+ worker_progress = Progress(
332
+ TextColumn("[bold cyan]{task.description}"),
333
+ BarColumn(bar_width=None, style="cyan"),
334
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
335
+ FFmpegDataColumn(), # Show FFmpeg data instead of time
336
+ console=console,
337
+ refresh_per_second=20
338
+ )
339
+
340
+ # Special progress for querying library with animated bar
341
+ query_progress = Progress(
342
+ SpinnerColumn(),
343
+ TextColumn("[bold green]{task.description}"),
344
+ AnimatedBarColumn(bar_width=None, style="green", complete_style="red", finished_style="green"),
345
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
346
+ MofNCompleteColumn(),
347
+ TimeElapsedColumn(),
348
+ console=console,
349
+ refresh_per_second=20
350
+ )
351
+
352
+ return main_progress, worker_progress, query_progress
353
+
354
+
355
+ def run_processing(config, selected_gpus):
356
+ """Run the main processing workflow."""
357
+ try:
358
+ # Get Plex server
359
+ plex = plex_server(config)
360
+
361
+ # Calculate title width for display formatting
362
+ title_max_width = calculate_title_width()
363
+
364
+ # Create worker pool
365
+ worker_pool = WorkerPool(
366
+ gpu_workers=config.gpu_threads,
367
+ cpu_workers=config.cpu_threads,
368
+ selected_gpus=selected_gpus
369
+ )
370
+
371
+ # Process all library sections
372
+ total_processed = 0
373
+
374
+ # Create progress displays
375
+ main_progress, worker_progress, query_progress = create_progress_displays()
376
+
377
+ # Create a dynamic group that can switch between query and processing displays
378
+ class DynamicGroup:
379
+ def __init__(self):
380
+ self.current_group = None
381
+
382
+ def set_query_mode(self):
383
+ self.current_group = Group(query_progress)
384
+
385
+ def set_processing_mode(self):
386
+ self.current_group = Group(main_progress, worker_progress)
387
+
388
+ def __rich_console__(self, console, options):
389
+ if self.current_group:
390
+ yield from self.current_group.__rich_console__(console, options)
391
+
392
+ dynamic_group = DynamicGroup()
393
+
394
+ with Live(dynamic_group, console=console, refresh_per_second=20):
395
+ # Start in query mode
396
+ dynamic_group.set_query_mode()
397
+ query_task = query_progress.add_task("Querying library...", total=1, completed=0)
398
+
399
+ # Get the generator for library sections
400
+ library_sections = get_library_sections(plex, config)
401
+
402
+ # Process all library sections
403
+ for section, media_items in library_sections:
404
+ if not media_items:
405
+ logger.info(f"No media items found in library '{section.title}', skipping")
406
+ continue
407
+
408
+ # Switch to processing mode
409
+ dynamic_group.set_processing_mode()
410
+ query_progress.remove_task(query_task)
411
+
412
+ main_task = main_progress.add_task(f"Processing {section.title}", total=len(media_items))
413
+
414
+ # Process items in this section with worker progress
415
+ worker_pool.process_items(media_items, config, plex, worker_progress, main_progress, main_task, title_max_width)
416
+ total_processed += len(media_items)
417
+
418
+ # Remove completed task
419
+ main_progress.remove_task(main_task)
420
+
421
+ # Switch back to query mode for next library
422
+ dynamic_group.set_query_mode()
423
+ query_task = query_progress.add_task("Querying library...", total=1, completed=0)
424
+
425
+ # Remove final query task
426
+ query_progress.remove_task(query_task)
427
+
428
+ logger.info(f'Successfully processed {total_processed} media items across all libraries')
429
+
430
+ except KeyboardInterrupt:
431
+ logger.info("Received interrupt signal, shutting down gracefully...")
432
+ except ConnectionError as e:
433
+ logger.error(f"Connection failed: {e}")
434
+ logger.error("Please fix the connection issue and try again.")
435
+ return 1
436
+ except Exception as e:
437
+ logger.error(f"Unexpected error in main execution: {e}")
438
+ raise
439
+ finally:
440
+ # Clean up worker pool
441
+ try:
442
+ if 'worker_pool' in locals():
443
+ worker_pool.shutdown()
444
+ except Exception as worker_error:
445
+ logger.warning(f"Failed to shutdown worker pool: {worker_error}")
446
+
447
+ # Clean up our working temp folder
448
+ try:
449
+ if os.path.isdir(config.working_tmp_folder):
450
+ shutil.rmtree(config.working_tmp_folder)
451
+ logger.debug(f"Cleaned up working temp folder: {config.working_tmp_folder}")
452
+ except Exception as cleanup_error:
453
+ logger.warning(f"Failed to clean up working temp folder {config.working_tmp_folder}: {cleanup_error}")
454
+
455
+
456
+ def main() -> None:
457
+ """Main entry point for the application."""
458
+ # Set up application (logging, arguments, config)
459
+ args, config = setup_application()
460
+ if config is None: # Handled --list-gpus flag
461
+ return
462
+
463
+ # Set up working directory
464
+ setup_working_directory(config)
465
+
466
+ # Detect and select GPUs
467
+ selected_gpus = detect_and_select_gpus(config)
468
+
469
+ # Run the main processing workflow
470
+ run_processing(config, selected_gpus)
471
+
472
+
473
+ if __name__ == '__main__':
474
+ main()