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.
- plex_generate_previews/__init__.py +10 -0
- plex_generate_previews/__main__.py +11 -0
- plex_generate_previews/cli.py +474 -0
- plex_generate_previews/config.py +479 -0
- plex_generate_previews/gpu_detection.py +541 -0
- plex_generate_previews/media_processing.py +439 -0
- plex_generate_previews/plex_client.py +211 -0
- plex_generate_previews/utils.py +135 -0
- plex_generate_previews/version_check.py +178 -0
- plex_generate_previews/worker.py +478 -0
- plex_generate_previews-2.0.0.dist-info/METADATA +728 -0
- plex_generate_previews-2.0.0.dist-info/RECORD +15 -0
- plex_generate_previews-2.0.0.dist-info/WHEEL +5 -0
- plex_generate_previews-2.0.0.dist-info/entry_points.txt +2 -0
- plex_generate_previews-2.0.0.dist-info/top_level.txt +1 -0
@@ -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")
|