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,478 @@
1
+ """
2
+ Worker classes for processing media items using threading.
3
+
4
+ Provides Worker and WorkerPool classes that use threading instead of
5
+ multiprocessing for better simplicity and performance with FFmpeg tasks.
6
+ """
7
+
8
+ import threading
9
+ import time
10
+ import shutil
11
+ from functools import partial
12
+ from typing import List, Optional, Any, Tuple
13
+ from loguru import logger
14
+
15
+ from .config import Config
16
+ from .media_processing import process_item
17
+ from .utils import format_display_title
18
+
19
+
20
+ class Worker:
21
+ """Represents a worker thread for processing media items."""
22
+
23
+ def __init__(self, worker_id: int, worker_type: str, gpu: Optional[str] = None,
24
+ gpu_device: Optional[str] = None, gpu_index: Optional[int] = None,
25
+ gpu_name: Optional[str] = None):
26
+ """
27
+ Initialize a worker.
28
+
29
+ Args:
30
+ worker_id: Unique identifier for this worker
31
+ worker_type: 'GPU' or 'CPU'
32
+ gpu: GPU type for acceleration
33
+ gpu_device: GPU device path
34
+ gpu_index: Index of the assigned GPU hardware
35
+ gpu_name: Human-readable GPU name for display
36
+ """
37
+ self.worker_id = worker_id
38
+ self.worker_type = worker_type
39
+ self.gpu = gpu
40
+ self.gpu_device = gpu_device
41
+ self.gpu_index = gpu_index
42
+ self.gpu_name = gpu_name
43
+
44
+ # Task state
45
+ self.is_busy = False
46
+ self.current_thread = None
47
+ self.current_task = None
48
+
49
+ # Progress tracking
50
+ self.progress_percent = 0
51
+ self.speed = "0.0x"
52
+ self.current_duration = 0.0
53
+ self.total_duration = 0.0
54
+ self.remaining_time = 0.0 # Remaining time calculated from FFmpeg data
55
+ self.task_title = ""
56
+ self.display_title = ""
57
+ self.media_title = ""
58
+ self.media_type = ""
59
+ self.title_max_width = 20
60
+ self.progress_task_id = None
61
+ self.ffmpeg_started = False # Track if FFmpeg has started outputting progress
62
+ self.task_start_time = 0 # Track when task started
63
+
64
+ # FFmpeg data fields for display
65
+ self.frame = 0
66
+ self.fps = 0
67
+ self.q = 0
68
+ self.size = 0
69
+ self.time_str = "00:00:00.00"
70
+ self.bitrate = 0
71
+
72
+ # Track last update to avoid unnecessary updates
73
+ self.last_progress_percent = -1
74
+ self.last_speed = ""
75
+ self.last_update_time = 0
76
+
77
+ # Statistics
78
+ self.completed = 0
79
+ self.failed = 0
80
+
81
+ def is_available(self) -> bool:
82
+ """Check if this worker is available for a new task."""
83
+ return not self.is_busy
84
+
85
+ def _format_gpu_name_for_display(self) -> str:
86
+ """Format GPU name for consistent display width."""
87
+ if not self.gpu_name:
88
+ return f"GPU {self.gpu_index}"
89
+
90
+ # Create a shortened version for display (exactly 10 characters for consistent alignment)
91
+ gpu_name = self.gpu_name
92
+
93
+ # Force shortening to 10 characters - be more aggressive
94
+ if len(gpu_name) > 10:
95
+ # Try to shorten intelligently
96
+ if "TITAN" in gpu_name and "RTX" in gpu_name:
97
+ gpu_name = "TITAN RTX" # Exactly 9 chars, will be padded to 10
98
+ elif "RTX" in gpu_name:
99
+ # Extract RTX model number
100
+ import re
101
+ match = re.search(r'RTX\s*(\d+)', gpu_name)
102
+ if match:
103
+ gpu_name = f"RTX{match.group(1)}"[:8]
104
+ elif "GTX" in gpu_name:
105
+ # Extract GTX model number
106
+ import re
107
+ match = re.search(r'GTX\s*(\d+)', gpu_name)
108
+ if match:
109
+ gpu_name = f"GTX{match.group(1)}"[:8]
110
+ elif "TITAN" in gpu_name:
111
+ gpu_name = "TITAN"
112
+ elif "Intel" in gpu_name:
113
+ gpu_name = "Intel"
114
+ elif "AMD" in gpu_name:
115
+ gpu_name = "AMD"
116
+ elif "GeForce" in gpu_name:
117
+ # Extract GeForce model
118
+ import re
119
+ match = re.search(r'GeForce\s+([A-Z0-9\s]+)', gpu_name)
120
+ if match:
121
+ return match.group(1).strip()[:8]
122
+ else:
123
+ # Just truncate to 8 characters
124
+ return gpu_name[:8]
125
+
126
+ # Always ensure it's exactly 10 characters
127
+ return gpu_name.ljust(10)[:10]
128
+
129
+ def assign_task(self, item_key: str, config: Config, plex, progress_callback=None,
130
+ media_title: str = "", media_type: str = "", title_max_width: int = 20) -> None:
131
+ """
132
+ Assign a new task to this worker.
133
+
134
+ Args:
135
+ item_key: Plex media item key to process
136
+ config: Configuration object
137
+ plex: Plex server instance
138
+ progress_callback: Callback function for progress updates
139
+ media_title: Media title for display
140
+ media_type: Media type ('episode' or 'movie')
141
+ title_max_width: Maximum width for title display
142
+ """
143
+ if self.is_busy:
144
+ raise RuntimeError(f"Worker {self.worker_id} is already busy")
145
+
146
+ # Reset all progress tracking to ensure clean state
147
+ self.is_busy = True
148
+ self.current_task = item_key
149
+ self.media_title = media_title
150
+ self.media_type = media_type
151
+ self.title_max_width = title_max_width
152
+ self.display_title = format_display_title(media_title, media_type, title_max_width)
153
+ # Show GPU name in display for GPU workers, show CPU identifier for CPU workers
154
+ if self.worker_type == 'GPU':
155
+ gpu_display = self._format_gpu_name_for_display()
156
+ self.task_title = f"[{gpu_display}]: {self.display_title}"
157
+ else:
158
+ self.task_title = f"[CPU ]: {self.display_title}"
159
+ self.progress_percent = 0
160
+ self.speed = "0.0x"
161
+ self.current_duration = 0.0
162
+ self.total_duration = 0.0
163
+ self.remaining_time = 0.0
164
+ self.ffmpeg_started = False
165
+ self.task_start_time = time.time()
166
+
167
+ # Reset FFmpeg data fields
168
+ self.frame = 0
169
+ self.fps = 0
170
+ self.q = 0
171
+ self.size = 0
172
+ self.time_str = "00:00:00.00"
173
+ self.bitrate = 0
174
+
175
+ # Reset tracking variables for clean state
176
+ self.last_progress_percent = -1
177
+ self.last_speed = ""
178
+ self.last_update_time = 0
179
+
180
+ # Start processing in background thread
181
+ self.current_thread = threading.Thread(
182
+ target=self._process_item,
183
+ args=(item_key, config, plex, progress_callback),
184
+ daemon=True
185
+ )
186
+ self.current_thread.start()
187
+
188
+ def _process_item(self, item_key: str, config: Config, plex, progress_callback=None) -> None:
189
+ """
190
+ Process a media item in the background thread.
191
+
192
+ Args:
193
+ item_key: Plex media item key
194
+ config: Configuration object
195
+ plex: Plex server instance
196
+ progress_callback: Callback function for progress updates
197
+ """
198
+ try:
199
+ process_item(item_key, self.gpu, self.gpu_device, config, plex, progress_callback)
200
+ self.completed += 1
201
+ except Exception as e:
202
+ logger.error(f"Worker {self.worker_id} failed to process {item_key}: {e}")
203
+ self.failed += 1
204
+
205
+ def check_completion(self) -> bool:
206
+ """
207
+ Check if this worker has completed its current task.
208
+
209
+ Returns:
210
+ bool: True if task completed, False if still running
211
+ """
212
+ if not self.is_busy:
213
+ return False # Worker is available, not completing
214
+
215
+ if self.current_thread and not self.current_thread.is_alive():
216
+ # Thread finished, mark as completed
217
+ self.is_busy = False
218
+ self.current_task = None
219
+ return True
220
+
221
+ return False
222
+
223
+ def get_progress_data(self) -> dict:
224
+ """Get current progress data for main thread."""
225
+ return {
226
+ 'progress_percent': self.progress_percent,
227
+ 'speed': self.speed,
228
+ 'task_title': self.task_title,
229
+ 'is_busy': self.is_busy,
230
+ 'current_duration': self.current_duration,
231
+ 'total_duration': self.total_duration,
232
+ 'remaining_time': self.remaining_time,
233
+ 'worker_id': self.worker_id, # Add worker ID for debugging
234
+ 'worker_type': self.worker_type, # Add worker type for debugging
235
+ # FFmpeg data for display
236
+ 'frame': self.frame,
237
+ 'fps': self.fps,
238
+ 'q': self.q,
239
+ 'size': self.size,
240
+ 'time_str': self.time_str,
241
+ 'bitrate': self.bitrate
242
+ }
243
+
244
+ def shutdown(self) -> None:
245
+ """Shutdown the worker gracefully."""
246
+ if self.current_thread and self.current_thread.is_alive():
247
+ # Wait for current task to complete (with timeout)
248
+ self.current_thread.join(timeout=5)
249
+
250
+ @staticmethod
251
+ def find_available(workers: List['Worker']) -> Optional['Worker']:
252
+ """
253
+ Find the first available worker.
254
+
255
+ GPU workers are prioritized (they come first in the array).
256
+
257
+ Args:
258
+ workers: List of Worker instances
259
+
260
+ Returns:
261
+ Worker: First available worker, or None if all are busy
262
+ """
263
+ for worker in workers:
264
+ if worker.is_available():
265
+ return worker
266
+ return None
267
+
268
+
269
+ class WorkerPool:
270
+ """Manages a pool of workers for processing media items."""
271
+
272
+ def __init__(self, gpu_workers: int, cpu_workers: int, selected_gpus: List[Tuple[str, str, dict]]):
273
+ """
274
+ Initialize worker pool.
275
+
276
+ Args:
277
+ gpu_workers: Number of GPU workers to create
278
+ cpu_workers: Number of CPU workers to create
279
+ selected_gpus: List of (gpu_type, gpu_device, gpu_info) tuples for GPU workers
280
+ """
281
+ self.workers = []
282
+ self._progress_lock = threading.Lock() # Thread-safe progress updates
283
+
284
+ # Add GPU workers first (prioritized) with round-robin GPU assignment
285
+ for i in range(gpu_workers):
286
+ # selected_gpus is guaranteed to be non-empty if gpu_workers > 0
287
+ # because detect_and_select_gpus() exits with error if no GPUs detected
288
+ gpu_index = i % len(selected_gpus)
289
+ gpu_type, gpu_device, gpu_info = selected_gpus[gpu_index]
290
+ gpu_name = gpu_info.get('name', f'{gpu_type} GPU')
291
+
292
+ worker = Worker(i, 'GPU', gpu_type, gpu_device, gpu_index, gpu_name)
293
+ self.workers.append(worker)
294
+
295
+ logger.info(f'GPU Worker {i} assigned to GPU {gpu_index} ({gpu_name})')
296
+
297
+ # Add CPU workers
298
+ for i in range(cpu_workers):
299
+ self.workers.append(Worker(i + gpu_workers, 'CPU'))
300
+
301
+ logger.info(f'Initialized {len(self.workers)} workers: {gpu_workers} GPU + {cpu_workers} CPU')
302
+
303
+ def has_busy_workers(self) -> bool:
304
+ """Check if any workers are currently busy."""
305
+ return any(worker.is_busy for worker in self.workers)
306
+
307
+ def has_available_workers(self) -> bool:
308
+ """Check if any workers are available for new tasks."""
309
+ return any(worker.is_available() for worker in self.workers)
310
+
311
+ def process_items(self, media_items: List[tuple], config: Config, plex, worker_progress, main_progress, main_task_id=None, title_max_width: int = 20) -> None:
312
+ """
313
+ Process all media items using available workers.
314
+
315
+ Uses dynamic task assignment - workers pull tasks as they become available.
316
+
317
+ Args:
318
+ media_items: List of tuples (key, title, media_type) to process
319
+ config: Configuration object
320
+ plex: Plex server instance
321
+ progress: Rich Progress object for displaying worker progress
322
+ main_task_id: ID of the main progress task to update
323
+ """
324
+ media_queue = list(media_items) # Copy the list
325
+ completed_tasks = 0
326
+
327
+ # Use provided title width for display formatting
328
+
329
+ logger.info(f'Processing {len(media_items)} items with {len(self.workers)} workers')
330
+
331
+ # Create progress tasks for each worker in the worker progress instance
332
+ for worker in self.workers:
333
+ # Show GPU name in initial task description for GPU workers, show CPU identifier for CPU workers
334
+ if worker.worker_type == 'GPU':
335
+ gpu_display = worker._format_gpu_name_for_display()
336
+ initial_desc = f"[{gpu_display}]: Idle - Waiting for task..."
337
+ else:
338
+ initial_desc = f"[CPU ]: Idle - Waiting for task..."
339
+
340
+ worker.progress_task_id = worker_progress.add_task(
341
+ initial_desc,
342
+ total=100,
343
+ completed=0,
344
+ speed="0.0x",
345
+ style="cyan"
346
+ )
347
+
348
+ # Process all items
349
+ while media_queue or self.has_busy_workers():
350
+ # Check for completed tasks and update progress
351
+ for worker in self.workers:
352
+ if worker.check_completion():
353
+ completed_tasks += 1
354
+ # Update main progress bar if main_task_id is provided
355
+ if main_task_id is not None:
356
+ main_progress.update(main_task_id, completed=completed_tasks)
357
+
358
+ # Update worker progress display with thread-safe access
359
+ current_time = time.time()
360
+
361
+ # Use thread-safe access to worker progress data
362
+ with self._progress_lock:
363
+ progress_data = worker.get_progress_data()
364
+ is_busy = worker.is_busy
365
+ ffmpeg_started = worker.ffmpeg_started
366
+
367
+ if is_busy:
368
+ # Update busy worker only if progress or speed changed and enough time has passed
369
+ should_update = (
370
+ (progress_data['progress_percent'] != worker.last_progress_percent or
371
+ progress_data['speed'] != worker.last_speed or
372
+ not ffmpeg_started) and
373
+ (current_time - worker.last_update_time > 0.05) # Throttle to 20fps for stability
374
+ )
375
+
376
+ if should_update:
377
+ # Use the formatted display title
378
+ worker_progress.update(
379
+ worker.progress_task_id,
380
+ description=worker.task_title,
381
+ completed=progress_data['progress_percent'],
382
+ speed=progress_data['speed'],
383
+ remaining_time=progress_data['remaining_time'],
384
+ # FFmpeg data for display
385
+ frame=progress_data['frame'],
386
+ fps=progress_data['fps'],
387
+ q=progress_data['q'],
388
+ size=progress_data['size'],
389
+ time_str=progress_data['time_str'],
390
+ bitrate=progress_data['bitrate']
391
+ )
392
+ worker.last_progress_percent = progress_data['progress_percent']
393
+ worker.last_speed = progress_data['speed']
394
+ worker.last_update_time = current_time
395
+ else:
396
+ # Update idle worker only if it was previously busy
397
+ if worker.last_progress_percent != -1:
398
+ # Show GPU name in idle display for GPU workers, show CPU identifier for CPU workers
399
+ if worker.worker_type == 'GPU':
400
+ gpu_display = worker._format_gpu_name_for_display()
401
+ idle_desc = f"[{gpu_display}]: Idle - Waiting for task..."
402
+ else:
403
+ idle_desc = f"[CPU ]: Idle - Waiting for task..."
404
+
405
+ worker_progress.update(
406
+ worker.progress_task_id,
407
+ description=idle_desc,
408
+ completed=0,
409
+ speed="0.0x"
410
+ )
411
+ worker.last_progress_percent = -1
412
+ worker.last_speed = ""
413
+
414
+ # Assign new tasks to available workers
415
+ while media_queue and self.has_available_workers():
416
+ available_worker = Worker.find_available(self.workers)
417
+ if available_worker:
418
+ item_key, media_title, media_type = media_queue.pop(0)
419
+
420
+ # Create progress callback using functools.partial
421
+ progress_callback = partial(self._update_worker_progress, available_worker)
422
+
423
+ available_worker.assign_task(
424
+ item_key,
425
+ config,
426
+ plex,
427
+ progress_callback=progress_callback,
428
+ media_title=media_title,
429
+ media_type=media_type,
430
+ title_max_width=title_max_width
431
+ )
432
+
433
+ # Adaptive sleep to balance responsiveness and CPU usage
434
+ if self.has_busy_workers():
435
+ time.sleep(0.01) # 10ms sleep for better stability with multiple workers
436
+
437
+ # Final statistics
438
+ total_completed = sum(worker.completed for worker in self.workers)
439
+ total_failed = sum(worker.failed for worker in self.workers)
440
+
441
+ # Clean up worker progress tasks
442
+ for worker in self.workers:
443
+ if hasattr(worker, 'progress_task_id') and worker.progress_task_id is not None:
444
+ worker_progress.remove_task(worker.progress_task_id)
445
+ worker.progress_task_id = None
446
+
447
+ logger.info(f'Processing complete: {total_completed} successful, {total_failed} failed')
448
+
449
+ def _update_worker_progress(self, worker, progress_percent, current_duration, total_duration, speed=None,
450
+ remaining_time=None, frame=0, fps=0, q=0, size=0, time_str="00:00:00.00", bitrate=0):
451
+ """Update worker progress data from callback."""
452
+ # Use thread-safe updates to prevent race conditions
453
+ with self._progress_lock:
454
+ worker.progress_percent = progress_percent
455
+ worker.current_duration = current_duration
456
+ worker.total_duration = total_duration
457
+ if speed:
458
+ worker.speed = speed
459
+ if remaining_time is not None:
460
+ worker.remaining_time = remaining_time
461
+
462
+ # Store FFmpeg data for display
463
+ worker.frame = frame
464
+ worker.fps = fps
465
+ worker.q = q
466
+ worker.size = size
467
+ worker.time_str = time_str
468
+ worker.bitrate = bitrate
469
+
470
+ # Mark that FFmpeg has started outputting progress
471
+ worker.ffmpeg_started = True
472
+
473
+ def shutdown(self) -> None:
474
+ """Shutdown all workers gracefully."""
475
+ logger.debug("Shutting down worker pool...")
476
+ for worker in self.workers:
477
+ worker.shutdown()
478
+ logger.debug("Worker pool shutdown complete")