hbs2/test
0
1subgen_version = '2026.06.3'2 3"""4ENVIRONMENT VARIABLES DOCUMENTATION5 6This application supports both new standardized environment variable names and legacy names for backwards compatibility. The new names follow a consistent naming convention: 7 8STANDARDIZED NAMING CONVENTION:9- Use UPPERCASE with underscores for separation10- Group related variables with consistent prefixes: 11 * PLEX_* for Plex server integration12 * JELLYFIN_* for Jellyfin server integration13 * PROCESS_* for media processing triggers14 * SKIP_* for all skip conditions15 * SUBTITLE_* for subtitle-related settings16 * WHISPER_* for Whisper model settings17 * TRANSCRIBE_* for transcription settings18 19BACKWARDS COMPATIBILITY: 20Legacy environment variable names are still supported. If both new and old names are set,21the new standardized name takes precedence. 22 23NEW NAME → OLD NAME (for backwards compatibility):24- PLEX_TOKEN → PLEXTOKEN25- PLEX_SERVER → PLEXSERVER26- JELLYFIN_TOKEN → JELLYFINTOKEN27- JELLYFIN_SERVER → JELLYFINSERVER28- PROCESS_ADDED_MEDIA → PROCADDEDMEDIA29- PROCESS_MEDIA_ON_PLAY → PROCMEDIAONPLAY30- SUBTITLE_LANGUAGE_NAME → NAMESUBLANG31- WEBHOOK_PORT → WEBHOOKPORT32- SKIP_IF_EXTERNAL_SUBTITLES_EXIST → SKIPIFEXTERNALSUB33- SKIP_IF_TARGET_SUBTITLES_EXIST → SKIP_IF_TO_TRANSCRIBE_SUB_ALREADY_EXIST34- SKIP_IF_INTERNAL_SUBTITLES_LANGUAGE → SKIPIFINTERNALSUBLANG35- SKIP_SUBTITLE_LANGUAGES → SKIP_LANG_CODES36- SKIP_IF_AUDIO_LANGUAGES → SKIP_IF_AUDIO_TRACK_IS37- SKIP_ONLY_SUBGEN_SUBTITLES → ONLY_SKIP_IF_SUBGEN_SUBTITLE38- SKIP_IF_NO_LANGUAGE_BUT_SUBTITLES_EXIST → SKIP_IF_LANGUAGE_IS_NOT_SET_BUT_SUBTITLES_EXIST39 40MIGRATION GUIDE:41Users can gradually migrate to the new names. Both will work simultaneously during the42transition period. The old names may be deprecated in future versions. 43"""44 45import ast46import asyncio47import ctypes48import ctypes.util49import gc50import hashlib51import json52import logging53import os54import queue55import subprocess56import sys57import threading58import time59import xml.etree.ElementTree as ET60from contextlib import asynccontextmanager61from datetime import datetime62from threading import Event, Lock, Timer63from typing import List, Union64 65import av66import faster_whisper67import ffmpeg68import numpy as np69import requests70import stable_whisper71import torch72from fastapi import Body, FastAPI, File, Form, Header, Query, Request, UploadFile73from fastapi.responses import StreamingResponse74from stable_whisper import Segment75from watchdog.events import FileSystemEventHandler76from watchdog.observers.polling import PollingObserver as Observer77 78from language_code import LanguageCode79 80 81def convert_to_bool(in_bool):82 # Convert the input to string and lower case, then check against true values83 return str(in_bool).lower() in ('true', 'on', '1', 'y', 'yes')84 85def get_env_with_fallback(new_name: str, old_name: str, default_value=None, convert_func=None):86 """87 Get environment variable with backwards compatibility fallback.88 89 Args:90 new_name: The new standardized environment variable name91 old_name: The legacy environment variable name for backwards compatibility92 default_value: Default value if neither variable is set93 convert_func: Optional function to convert the value (e.g., convert_to_bool, int)94 95 Returns:96 The environment variable value, converted if convert_func is provided97 """98 # Try new name first, then fall back to old name99 value = os.getenv(new_name) or os.getenv(old_name)100 101 if value is None:102 value = default_value103 104 # Apply conversion function if provided105 if convert_func and value is not None:106 return convert_func(value)107 108 return value109 110# Server Integration - with backwards compatibility111plextoken = get_env_with_fallback('PLEX_TOKEN', 'PLEXTOKEN', 'token here')112plexserver = get_env_with_fallback('PLEX_SERVER', 'PLEXSERVER', 'http://192.168.1.111:32400')113jellyfintoken = get_env_with_fallback('JELLYFIN_TOKEN', 'JELLYFINTOKEN', 'token here')114jellyfinserver = get_env_with_fallback('JELLYFIN_SERVER', 'JELLYFINSERVER', 'http://192.168.1.111:8096')115 116# Whisper Configuration117whisper_model = os.getenv('WHISPER_MODEL', 'distil-small.en')118whisper_threads = int(os.getenv('WHISPER_THREADS', 15))119concurrent_transcriptions = int(os.getenv('CONCURRENT_TRANSCRIPTIONS', 1))120transcribe_device = os.getenv('TRANSCRIBE_DEVICE', 'gpu')121 122# Processing Control - with backwards compatibility123procaddedmedia = get_env_with_fallback('PROCESS_ADDED_MEDIA', 'PROCADDEDMEDIA', True, convert_to_bool)124procmediaonplay = get_env_with_fallback('PROCESS_MEDIA_ON_PLAY', 'PROCMEDIAONPLAY', True, convert_to_bool)125 126# Subtitle Configuration - with backwards compatibility127subtitle_language_name = get_env_with_fallback('SUBTITLE_LANGUAGE_NAME', 'NAMESUBLANG', '')128 129# System Configuration - with backwards compatibility130webhookport = get_env_with_fallback('WEBHOOK_PORT', 'WEBHOOKPORT', 9000, int)131word_level_highlight = convert_to_bool(os.getenv('WORD_LEVEL_HIGHLIGHT', False))132debug = convert_to_bool(os.getenv('DEBUG', True))133use_path_mapping = convert_to_bool(os.getenv('USE_PATH_MAPPING', False))134path_mapping_from = os.getenv('PATH_MAPPING_FROM', r'/tv')135path_mapping_to = os.getenv('PATH_MAPPING_TO', r'/Volumes/TV')136model_location = os.getenv('MODEL_PATH', './models')137monitor = convert_to_bool(os.getenv('MONITOR', False))138transcribe_folders = os.getenv('TRANSCRIBE_FOLDERS', '')139transcribe_or_translate = os.getenv('TRANSCRIBE_OR_TRANSLATE', 'transcribe').lower()140clear_vram_on_complete = convert_to_bool(os.getenv('CLEAR_VRAM_ON_COMPLETE', True))141compute_type = os.getenv('COMPUTE_TYPE', 'auto')142append = convert_to_bool(os.getenv('APPEND', False))143reload_script_on_change = convert_to_bool(os.getenv('RELOAD_SCRIPT_ON_CHANGE', False))144lrc_for_audio_files = convert_to_bool(os.getenv('LRC_FOR_AUDIO_FILES', True))145custom_regroup = os.getenv('CUSTOM_REGROUP', 'cm_sl=84_sl=42++++++1')146detect_language_length = int(os.getenv('DETECT_LANGUAGE_LENGTH', 30))147detect_language_offset = int(os.getenv('DETECT_LANGUAGE_OFFSET', 0))148model_cleanup_delay = int(os.getenv('MODEL_CLEANUP_DELAY', 30))149asr_timeout = int(os.getenv('ASR_TIMEOUT', 18000))150webhook_url_completed = os.getenv('WEBHOOK_URL_COMPLETED', '')151 152# Skip Configuration - with backwards compatibility153skip_if_external_sub_exists = get_env_with_fallback('SKIP_IF_EXTERNAL_SUBTITLES_EXIST', 'SKIPIFEXTERNALSUB', False, convert_to_bool)154skip_if_target_subtitle_exists = get_env_with_fallback('SKIP_IF_TARGET_SUBTITLES_EXIST', 'SKIP_IF_TO_TRANSCRIBE_SUB_ALREADY_EXIST', True, convert_to_bool)155skip_if_internal_sub_language = LanguageCode.from_string(get_env_with_fallback('SKIP_IF_INTERNAL_SUBTITLES_LANGUAGE', 'SKIPIFINTERNALSUBLANG', ''))156ignore_forced_subtitles = convert_to_bool(os.getenv('IGNORE_FORCED_SUBTITLES', True))157plex_queue_next_episode = convert_to_bool(os.getenv('PLEX_QUEUE_NEXT_EPISODE', False))158plex_queue_season = convert_to_bool(os.getenv('PLEX_QUEUE_SEASON', False))159plex_queue_series = convert_to_bool(os.getenv('PLEX_QUEUE_SERIES', False))160# Language and Skip Configuration - with backwards compatibility161skip_subtitle_languages = ([LanguageCode.from_string(code) for code in get_env_with_fallback('SKIP_SUBTITLE_LANGUAGES', 'SKIP_LANG_CODES', '').split("|")]162 if get_env_with_fallback('SKIP_SUBTITLE_LANGUAGES', 'SKIP_LANG_CODES')163 else[]164)165force_detected_language_to = LanguageCode.from_string(os.getenv('FORCE_DETECTED_LANGUAGE_TO', ''))166preferred_audio_languages =[167 LanguageCode.from_string(code) 168 for code in os.getenv('PREFERRED_AUDIO_LANGUAGES', 'eng').split("|")169] # in order of preference170limit_to_preferred_audio_languages = convert_to_bool(os.getenv('LIMIT_TO_PREFERRED_AUDIO_LANGUAGE', False))171skip_audio_languages = ([LanguageCode.from_string(code) for code in get_env_with_fallback('SKIP_IF_AUDIO_LANGUAGES', 'SKIP_IF_AUDIO_TRACK_IS', '').split("|")]172 if get_env_with_fallback('SKIP_IF_AUDIO_LANGUAGES', 'SKIP_IF_AUDIO_TRACK_IS')173 else[]174)175 176# Additional Subtitle Configuration - with backwards compatibility177subtitle_language_naming_type = os.getenv('SUBTITLE_LANGUAGE_NAMING_TYPE', 'ISO_639_2_B')178only_match_subgen_subtitles = get_env_with_fallback('SKIP_ONLY_SUBGEN_SUBTITLES', 'ONLY_SKIP_IF_SUBGEN_SUBTITLE', False, convert_to_bool)179skip_unknown_language = convert_to_bool(os.getenv('SKIP_UNKNOWN_LANGUAGE', False))180skip_if_no_audio_language_but_subtitles_exist = get_env_with_fallback('SKIP_IF_NO_LANGUAGE_BUT_SUBTITLES_EXIST', 'SKIP_IF_LANGUAGE_IS_NOT_SET_BUT_SUBTITLES_EXIST', False, convert_to_bool)181ignore_forced_subtitles = convert_to_bool(os.getenv('IGNORE_FORCED_SUBTITLES', True))182should_whisper_detect_audio_language = convert_to_bool(os.getenv('SHOULD_WHISPER_DETECT_AUDIO_LANGUAGE', False))183show_in_subname_subgen = convert_to_bool(os.getenv('SHOW_IN_SUBNAME_SUBGEN', True))184show_in_subname_model = convert_to_bool(os.getenv('SHOW_IN_SUBNAME_MODEL', True))185 186# Advanced Configuration187try:188 kwargs = ast.literal_eval(os.getenv('SUBGEN_KWARGS', '{}') or '{}')189except ValueError:190 kwargs = {}191 logging.info("kwargs (SUBGEN_KWARGS) is an invalid dictionary, defaulting to empty '{}'")192 193if transcribe_device == "gpu":194 transcribe_device = "cuda"195 196VIDEO_EXTENSIONS = (197 ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mpg", ".mpeg", 198 ".3gp", ".ogv", ".vob", ".rm", ".rmvb", ".ts", ".m4v", ".f4v", ".svq3", 199 ".asf", ".m2ts", ".divx", ".xvid"200)201 202AUDIO_EXTENSIONS = (203 ".mp3", ".wav", ".aac", ".flac", ".ogg", ".wma", ".alac", ".m4a", ".opus", 204 ".aiff", ".aif", ".pcm", ".ra", ".ram", ".mid", ".midi", ".ape", ".wv", 205 ".amr", ".vox", ".tak", ".spx", ".m4b", ".mka"206)207 208@asynccontextmanager209async def lifespan(app: FastAPI):210 if transcribe_folders:211 threading.Thread(target=transcribe_existing, args=(transcribe_folders,), daemon=True).start()212 yield213 214app = FastAPI(lifespan=lifespan)215 216model = None217model_cleanup_timer = None218model_cleanup_lock = Lock()219 220# Locks to ensure thread-safety during concurrent AI operations221model_load_lock = Lock()222active_direct_tasks = 0223active_direct_tasks_lock = Lock()224 225in_docker = os.path.exists('/.dockerenv')226docker_status = "Docker" if in_docker else "Standalone"227 228# ============================================================================229# TASK RESULT STORAGE (for blocking endpoints)230# ============================================================================231 232class TaskResult:233 """Stores the result of a queued task for blocking retrieval"""234 def __init__(self):235 self.result = None236 self.error = None237 self.done = Event()238 239 def set_result(self, result):240 self.result = result241 self.done.set()242 243 def set_error(self, error):244 self.error = error245 self.done.set()246 247 def wait(self, timeout=None):248 """Block until result is ready. Returns True if completed, False if timeout."""249 return self.done.wait(timeout)250 251# Dictionary to store task results keyed by task_id252# Entries are cleaned up in /asr endpoint finally block to prevent unbounded growth253task_results = {}254task_results_lock = Lock()255 256# ============================================================================257# HASH GENERATION FOR DEDUPLICATION258# ============================================================================259 260def generate_audio_hash(audio_content: bytes, task: str = None, language: str = None) -> str:261 """262 Generate a deterministic hash from audio content and optional parameters. 263 264 Same audio + same task + same language = always same hash. 265 This ensures duplicate requests are caught by the queue. 266 267 Args:268 audio_content: Raw audio bytes from uploaded file269 task: Optional task type ('transcribe' or 'translate')270 language: Optional target language code271 272 Returns: 273 SHA256 hash (first 16 chars for brevity in logs)274 """275 hash_input = audio_content276 277 # Include task and language for fine-grained deduplication278 if task:279 hash_input += task.encode('utf-8')280 if language:281 hash_input += language.encode('utf-8')282 283 full_hash = hashlib.sha256(hash_input).hexdigest()284 return full_hash[:16] # Use first 16 chars for shorter IDs in logs285 286# ============================================================================287# REFACTORED DEDUPLICATED QUEUE WITH BETTER TRACKING288# ============================================================================289 290class DeduplicatedQueue(queue.PriorityQueue):291 """Queue that prevents duplicates, handles priority, and tracks status."""292 def __init__(self):293 super().__init__()294 self._queued = set() # Tracks task IDs waiting in queue295 self._processing = set() # Tracks task IDs currently being handled296 self._lock = Lock()297 298 def put(self, item, block=True, timeout=None):299 with self._lock:300 task_id = item["path"]301 if task_id not in self._queued and task_id not in self._processing:302 # Priority: 0 (Detect), 1 (ASR), 2 (Transcribe)303 task_type = item.get("type", "transcribe")304 priority = 0 if task_type == "detect_language" else (1 if task_type == "asr" else 2)305 306 # PriorityQueue requires a tuple: (priority, tie_breaker, item)307 super().put((priority, time.time(), item), block, timeout)308 self._queued.add(task_id)309 return True310 return False311 312 def get(self, block=True, timeout=None):313 # PriorityQueue returns the tuple, we want just the item314 priority, timestamp, item = super().get(block, timeout)315 with self._lock:316 task_id = item["path"]317 self._queued.discard(task_id)318 self._processing.add(task_id)319 return item320 321 def mark_done(self, item):322 with self._lock:323 task_id = item["path"]324 self._processing.discard(task_id)325 326 def is_idle(self):327 with self._lock:328 return self.empty() and len(self._processing) == 0329 330 def is_active(self, task_id):331 """Checks if a task_id is currently queued or processing."""332 with self._lock:333 return task_id in self._queued or task_id in self._processing334 335 def get_queued_tasks(self):336 with self._lock:337 return list(self._queued)338 339 def get_processing_tasks(self):340 with self._lock:341 return list(self._processing)342 343# Start queue344task_queue = DeduplicatedQueue()345 346# ============================================================================347# TRANSCRIPTION WORKER348# ============================================================================349 350def transcription_worker():351 """Main worker thread with centralized logging and status tracking."""352 while True:353 task = None354 next_task = None355 try:356 task = task_queue.get(block=True, timeout=1)357 task_type = task.get("type", "transcribe")358 path = task.get("path", "unknown")359 display_name = os.path.basename(path) if ("/" in str(path) or "\\" in str(path)) else path360 361 # Status for START log362 proc_count = len(task_queue.get_processing_tasks())363 queue_count = len(task_queue.get_queued_tasks())364 logging.info(f"WORKER START :[{task_type.upper():<10}] {display_name:^40} | Jobs: {proc_count} processing, {queue_count} queued")365 366 start_time = time.time()367 if task_type == "detect_language": 368 if "audio_content" in task: 369 detect_language_from_upload(task)370 else: 371 # Capture the transcription task to queue later372 next_task = detect_language_task(task['path'], original_task_data=task)373 elif task_type == "asr":374 asr_task_worker(task)375 else: # transcribe376 gen_subtitles(task['path'], task['transcribe_or_translate'], task['force_language'], audio_tracks=task.get('audio_tracks'))377 378 # --- METADATA REFRESH LOGIC ---379 if 'plex_item_id' in task:380 try:381 logging.info(f"Refreshing Plex Metadata for item {task['plex_item_id']}")382 refresh_plex_metadata(task['plex_item_id'], task['plex_server'], task['plex_token'])383 except Exception as e:384 logging.error(f"Failed to refresh Plex metadata: {e}")385 386 if 'jellyfin_item_id' in task:387 try:388 logging.info(f"Refreshing Jellyfin Metadata for item {task['jellyfin_item_id']}")389 refresh_jellyfin_metadata(task['jellyfin_item_id'], task['jellyfin_server'], task['jellyfin_token'])390 except Exception as e:391 logging.error(f"Failed to refresh Jellyfin metadata: {e}")392 # ------------------------------393 394 # Status for FINISH log395 elapsed = time.time() - start_time396 m, s = divmod(int(elapsed), 60)397 remaining_queued = len(task_queue.get_queued_tasks())398 logging.info(f"WORKER FINISH: [{task_type.upper():<10}] {display_name:^40} in {m}m {s}s | Remaining: {remaining_queued} queued")399 400 except queue.Empty:401 continue402 except Exception as e:403 logging.error(f"Error processing task: {e}", exc_info=True)404 finally:405 if task:406 task_queue.task_done()407 task_queue.mark_done(task)408 409 # Now that the detect task is removed from processing, it's safe to queue the transcription410 if next_task:411 if task_queue.put(next_task):412 logging.debug(f"Queued transcription for detected language: {next_task['path']}")413 else:414 logging.debug(f"Transcription already queued/processing for: {next_task['path']}")415 416 delete_model()417 418# Create worker threads419for _ in range(concurrent_transcriptions):420 threading.Thread(target=transcription_worker, daemon=True).start()421 422# Define a filter class to hide common logging we don't want to see423class MultiplePatternsFilter(logging.Filter):424 def filter(self, record):425 # Define the patterns to search for426 patterns =[427 "Compression ratio threshold is not met",428 "Processing segment at",429 "Log probability threshold is",430 "Reset prompt",431 "Attempting to release",432 "released on ",433 "Attempting to acquire",434 "acquired on",435 "header parsing failed",436 "timescale not set",437 "misdetection possible",438 "srt was added",439 "doesn't have any audio to transcribe",440 "Calling on_"441 ]442 # Return False if any of the patterns are found, True otherwise443 return not any(pattern in record.getMessage() for pattern in patterns)444 445# Configure logging446if debug:447 level = logging.DEBUG448else:449 level = logging.INFO450 451logging.basicConfig(452 stream=sys.stderr, 453 level=level, 454 format="%(asctime)s %(levelname)s: %(message)s",455 datefmt="%Y-%m-%d %H:%M:%S" # This removes the ,123 part456)457 458# Get the root logger459logger = logging.getLogger()460logger.setLevel(level) # Set the logger level461 462for handler in logger.handlers:463 handler.addFilter(MultiplePatternsFilter())464 465logging.getLogger("multipart").setLevel(logging.WARNING)466logging.getLogger("urllib3").setLevel(logging.WARNING)467logging.getLogger("watchfiles").setLevel(logging.WARNING)468logging.getLogger("asyncio").setLevel(logging.WARNING)469logging.getLogger("httpcore").setLevel(logging.WARNING)470logging.getLogger("httpx").setLevel(logging.WARNING)471logging.getLogger("huggingface_hub").setLevel(logging.WARNING)472 473 474class ProgressHandler:475 def __init__(self, filename):476 self.filename = filename477 self.start_time = time.time()478 self.last_print_time = 0479 self.interval = 5480 481 @staticmethod482 def _fmt_t(seconds):483 """Format seconds as [H:]MM:SS without milliseconds."""484 m, s = divmod(int(seconds), 60)485 h, m = divmod(m, 60)486 if h > 0:487 return f"{h}:{m:02d}:{s:02d}"488 return f"{m:02d}:{s:02d}"489 490 def __call__(self, seek, total):491 if docker_status == 'Docker' or debug:492 current_time = time.time()493 if self.last_print_time == 0 or (current_time - self.last_print_time) >= self.interval:494 self.last_print_time = current_time495 496 pct = int((seek / total) * 100) if total > 0 else 0497 elapsed = current_time - self.start_time498 speed = seek / elapsed if elapsed > 0 else 0499 eta = (total - seek) / speed if speed > 0 else 0500 501 proc = len(task_queue.get_processing_tasks())502 queued = len(task_queue.get_queued_tasks())503 504 clean_name = (self.filename[:37] + '..') if len(self.filename) > 40 else self.filename505 506 logging.info(507 f"[ {clean_name:<40}] {pct:>3}% | "508 f"{int(seek):>5}/{int(total):<5}s "509 f"[{self._fmt_t(elapsed):>5}<{self._fmt_t(eta):>5}, {speed:>5.2f}s/s] | "510 f"Jobs: {proc} processing, {queued} queued"511 )512 513TIME_OFFSET = 5514 515def appendLine(result):516 if append:517 lastSegment = result.segments[-1]518 date_time_str = datetime.now().strftime("%d %b %Y - %H:%M:%S")519 appended_text = f"Transcribed by whisperAI with faster-whisper ({whisper_model}) on {date_time_str}"520 521 # Create a new segment with the updated information522 newSegment = Segment(523 start=lastSegment.start + TIME_OFFSET,524 end=lastSegment.end + TIME_OFFSET,525 text=appended_text,526 words=[], # Empty list for words527 id=lastSegment.id + 1528 )529 530 # Append the new segment to the result's segments531 result.segments.append(newSegment)532 533@app.get("/plex")534@app.get("/webhook")535@app.get("/jellyfin")536@app.get("/asr")537@app.get("/emby")538@app.get("/detect-language")539@app.get("/tautulli")540def handle_get_request(request: Request):541 return {"You accessed this request incorrectly via a GET request. See https://github.com/McCloudS/subgen for proper configuration"}542 543@app.get("/")544def webui():545 return {"The webui for configuration was removed on 1 October 2024, please configure via environment variables or in your Docker settings. "}546 547@app.get("/status")548def status():549 return {"version": f"Subgen {subgen_version}, stable-ts {stable_whisper.__version__}, faster-whisper {faster_whisper.__version__} ({docker_status})"}550 551@app.post("/tautulli")552def receive_tautulli_webhook(553 source: Union[str, None] = Header(None),554 event: str = Body(None),555 file: str = Body(None),556):557 if source == "Tautulli":558 logging.debug(f"Tautulli event detected is: {event}")559 if((event == "added" and procaddedmedia) or (event == "played" and procmediaonplay)):560 fullpath = file561 logging.debug(f"Full file path: {fullpath}")562 563 gen_subtitles_queue(path_mapping(fullpath), transcribe_or_translate)564 else:565 return {566 "message": "This doesn't appear to be a properly configured Tautulli webhook, please review the instructions again!"}567 568 return ""569 570@app.post("/plex")571def receive_plex_webhook(572 user_agent: Union[str] = Header(None),573 payload: Union[str] = Form(),574):575 try:576 plex_json = json.loads(payload)577 if "PlexMediaServer" not in user_agent:578 return {"message": "This doesn't appear to be a properly configured Plex webhook, please review the instructions again"}579 580 event = plex_json["event"]581 logging.debug(f"Plex event detected is: {event}")582 583 if (event == "library.new" and procaddedmedia) or (event == "media.play" and procmediaonplay):584 rating_key = plex_json['Metadata']['ratingKey']585 fullpath = get_plex_file_name(rating_key, plexserver, plextoken)586 logging.debug(f"Full file path: {fullpath}")587 588 # Queue the current item with its specific ID for refreshing589 gen_subtitles_queue(590 path_mapping(fullpath), 591 transcribe_or_translate, 592 plex_item_id=rating_key, 593 plex_server=plexserver, 594 plex_token=plextoken595 )596 597 # Note: refresh_plex_metadata is removed here; it is now handled by the worker thread.598 599 if plex_queue_next_episode:600 next_key = get_next_plex_episode(plex_json['Metadata']['ratingKey'], stay_in_season=False)601 if next_key:602 next_file = get_plex_file_name(next_key, plexserver, plextoken)603 gen_subtitles_queue(604 path_mapping(next_file), 605 transcribe_or_translate,606 plex_item_id=next_key, # Pass the NEXT ID so it refreshes when done607 plex_server=plexserver,608 plex_token=plextoken609 )610 611 if plex_queue_series or plex_queue_season:612 current_rating_key = plex_json['Metadata']['ratingKey']613 stay_in_season = plex_queue_season # Determine if we're staying in the season or not614 615 while current_rating_key is not None:616 try:617 # Queue the current episode618 file_path = path_mapping(get_plex_file_name(current_rating_key, plexserver, plextoken))619 620 gen_subtitles_queue(621 file_path, 622 transcribe_or_translate,623 plex_item_id=current_rating_key, # Pass the specific loop ID for refreshing624 plex_server=plexserver,625 plex_token=plextoken626 )627 628 logging.debug(f"Queued episode with ratingKey {current_rating_key}")629 630 # Get the next episode631 next_episode_rating_key = get_next_plex_episode(current_rating_key, stay_in_season=stay_in_season)632 if next_episode_rating_key is None:633 break # Exit the loop if no next episode634 current_rating_key = next_episode_rating_key635 636 except Exception as e:637 logging.error(f"Error processing episode with ratingKey {current_rating_key} or reached end of series: {e}")638 break # Stop processing on error639 640 logging.info("All episodes in the series (or season) have been queued.")641 642 except Exception as e:643 logging.error(f"Failed to process Plex webhook: {e}")644 645 return ""646 647@app.post("/jellyfin")648def receive_jellyfin_webhook(649 user_agent: str = Header(None),650 NotificationType: str = Body(None),651 file: str = Body(None),652 ItemId: str = Body(None),653):654 if "Jellyfin-Server" in user_agent:655 logging.debug(f"Jellyfin event detected is: {NotificationType}")656 logging.debug(f"itemid is: {ItemId}")657 658 if (NotificationType == "ItemAdded" and procaddedmedia) or (NotificationType == "PlaybackStart" and procmediaonplay):659 fullpath = get_jellyfin_file_name(ItemId, jellyfinserver, jellyfintoken)660 logging.debug(f"Full file path: {fullpath}")661 662 # Queue item with Jellyfin metadata ID for delayed refresh663 gen_subtitles_queue(664 path_mapping(fullpath), 665 transcribe_or_translate,666 jellyfin_item_id=ItemId,667 jellyfin_server=jellyfinserver,668 jellyfin_token=jellyfintoken669 )670 671 # Note: refresh_jellyfin_metadata removed here; handled by worker.672 else:673 return {674 "message": "This doesn't appear to be a properly configured Jellyfin webhook, please review the instructions again!"}675 676 return ""677 678@app.post("/emby")679def receive_emby_webhook(680 user_agent: Union[str, None] = Header(None),681 data: Union[str, None] = Form(None),682):683 if not data:684 return ""685 686 data_dict = json.loads(data)687 event = data_dict['Event']688 logging.debug("Emby event detected is: " + event)689 690 # Check if it's a notification test event691 if event == "system.notificationtest":692 logging.info("Emby test message received!")693 return {"message": "Notification test received successfully!"}694 695 if (event == "library.new" and procaddedmedia) or (event == "playback.start" and procmediaonplay):696 fullpath = data_dict['Item']['Path']697 logging.debug(f"Full file path: {fullpath}")698 gen_subtitles_queue(path_mapping(fullpath), transcribe_or_translate)699 700 return ""701 702@app.post("/batch")703def batch(704 directory: str = Query(...),705 forceLanguage: Union[str, None] = Query(default=None)706):707 transcribe_existing(directory, LanguageCode.from_string(forceLanguage))708 709# ============================================================================710# REFACTORED /ASR ENDPOINT WITH HASH-BASED DEDUPLICATION AND BLOCKING711# ============================================================================712 713@app.post("/asr")714async def asr(715 task: Union[str, None] = Query(default="transcribe", enum=["transcribe", "translate"]),716 language: Union[str, None] = Query(default=None),717 video_file: Union[str, None] = Query(default=None),718 initial_prompt: Union[str, None] = Query(default=None),719 audio_file: UploadFile = File(...),720 encode: bool = Query(default=True, description="Encode audio first through ffmpeg"),721 output: Union[str, None] = Query(default="srt", enum=["txt", "vtt", "srt", "tsv", "json"]),722 word_timestamps: bool = Query(default=False, description="Word-level timestamps"),723):724 """725 ASR endpoint that uses audio content hash for deduplication. 726 BLOCKS until processing is complete, then returns the result.727 728 If identical audio + task + language is already being processed,729 waits for that task to complete and returns the same result.730 """731 task_id = None732 733 try:734 logging.info(735 f"ASR {task.capitalize()} received for file '{video_file}'" 736 if video_file 737 else f"ASR {task.capitalize()} received"738 )739 740 # Read audio file content into memory741 file_content = await audio_file.read()742 743 if not file_content:744 await audio_file.close()745 return {746 "status": "error",747 "message": "Audio file is empty"748 }749 750 # Generate deterministic hash from audio (and optionally task/language)751 audio_hash = generate_audio_hash(file_content, task, language)752 753 # FIX: Use video file path if available to match TRANSCRIBE tasks754 if video_file:755 task_id = path_mapping(video_file)756 logging.debug(f"Using mapped video file path as task ID for ASR request: {task_id}")757 else:758 task_id = f"asr-{audio_hash}"759 logging.debug(f"Generated audio hash: {audio_hash} for ASR request")760 761 # Handle forced language762 final_language = language763 if force_detected_language_to: 764 final_language = force_detected_language_to.to_iso_639_1()765 logging.info(f"Forcing detected language to {force_detected_language_to}")766 767 # Create result container for this task768 with task_results_lock:769 if task_id not in task_results:770 task_results[task_id] = TaskResult()771 task_result = task_results[task_id]772 773 # Queue the ASR task774 asr_task_data = {775 'path': task_id, # DeduplicatedQueue uses this for dedup776 'type': 'asr',777 'task': task,778 'language': final_language,779 'video_file': video_file,780 'initial_prompt': initial_prompt,781 'audio_content': file_content,782 'encode': encode,783 'output': output,784 'word_timestamps': word_timestamps,785 'result_container': task_result,786 }787 788 # Try to queue (returns False if already queued/processing)789 if task_queue.put(asr_task_data):790 logging.info(f"ASR task {task_id} queued")791 else:792 logging.info(f"ASR task {task_id} already queued/processing - waiting for result")793 794 # EVENT LOOP BLOCK FIX: Use asyncio.to_thread so FastAPI can still respond to /status795 if await asyncio.to_thread(task_result.wait, asr_timeout):796 if task_result.error:797 logging.error(f"ASR task {task_id} failed: {task_result.error}")798 return {799 "status": "error",800 "task_id": task_id,801 "message": f"ASR processing failed: {task_result.error}"802 }803 else: 804 logging.info(f"ASR task {task_id} completed")805 return StreamingResponse(806 iter(task_result.result),807 media_type="text/plain",808 headers={'Source': f'{task.capitalize()}d using stable-ts from Subgen!'}809 )810 else:811 logging.error(f"ASR task {task_id} timed out")812 return {813 "status": "timeout",814 "task_id": task_id,815 "message": f"ASR processing timed out after {asr_timeout} seconds"816 }817 818 except Exception as e: 819 logging.error(f"Error in ASR endpoint: {e}", exc_info=True)820 return {"status": "error", "message": f"Error: {str(e)}"}821 finally:822 await audio_file.close()823 # Clean up task_results entry after task completes824 with task_results_lock:825 if task_id in task_results:826 del task_results[task_id]827 logging.debug(f"Cleaned up task_results entry for {task_id}")828 829# ============================================================================830# ASR WORKER FUNCTION831# ============================================================================832 833def get_audio_start_time(video_path: str) -> float:834 """835 Use ffprobe to detect the audio stream start_time offset from a video file.836 837 Some containers (especially Amazon WEB-DL) have audio streams that start838 later than the video stream. Bazarr compensates with adelay silence padding,839 but Whisper ignores digital silence, causing all timestamps to be early by840 the start_time offset.841 842 Returns the audio start_time in seconds, or 0.0 if not detectable.843 """844 if not video_path or not os.path.isfile(video_path):845 return 0.0846 847 try:848 result = subprocess.run(['ffprobe', '-v', 'error', '-select_streams', 'a:0',849 '-show_entries', 'stream=start_time',850 '-of', 'json', video_path],851 capture_output=True, text=True, timeout=10852 )853 if result.returncode != 0:854 return 0.0855 856 data = json.loads(result.stdout)857 streams = data.get('streams', [])858 if streams:859 start_time = float(streams[0].get('start_time', 0))860 if start_time > 0.1: # only apply for significant offsets861 logging.info(f"Detected audio start_time offset: {start_time:.3f}s for {os.path.basename(video_path)}")862 return start_time863 except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError, OSError) as e:864 logging.debug(f"Could not detect audio start_time for {video_path}: {e}")865 866 return 0.0867 868 869def apply_timestamp_offset(result, offset: float) -> None:870 """871 Shift all segment and word timestamps forward by the given offset.872 873 This compensates for audio start_time offsets in containers where the874 audio stream starts later than the video stream. Whisper produces875 timestamps relative to the audio stream start, but subtitles need876 to be aligned to the video/container timeline.877 878 Note: Segment.start/end are properties that delegate to the first/last879 word timestamps, so we only need to shift word timestamps to avoid880 double-application. For segments without words, we shift _default_start/end.881 """882 if offset <= 0:883 return884 885 for segment in result.segments:886 if hasattr(segment, 'words') and segment.words:887 for word in segment.words:888 word.start += offset889 word.end += offset890 else:891 # Segments without words use _default_start/_default_end892 if hasattr(segment, '_default_start'):893 segment._default_start += offset894 if hasattr(segment, '_default_end'):895 segment._default_end += offset896 897 logging.info(f"Applied +{offset:.3f}s timestamp offset to {len(result.segments)} segments")898 899 900def asr_task_worker(task_data: dict) -> None:901 """902 Worker function that processes ASR tasks from the queue. 903 Called by transcription_worker when task type is 'asr'.904 """905 result = None906 task_id = task_data.get('path', 'unknown')907 result_container = task_data.get('result_container')908 909 try:910 task = task_data['task']911 language = task_data['language']912 video_file = task_data.get('video_file')913 _initial_prompt = task_data.get('initial_prompt')914 file_content = task_data['audio_content']915 encode = task_data['encode']916 917 start_model()918 919 args = {}920 display_name = os.path.basename(video_file) if video_file else task_id921 args['progress_callback'] = ProgressHandler(display_name)922 923 # Handle audio encoding924 if encode:925 args['audio'] = file_content926 else:927 args['audio'] = np.frombuffer(file_content, np.int16).flatten().astype(np.float32) / 32768.0928 args['input_sr'] = 16000929 930 if custom_regroup and custom_regroup.lower() != 'default':931 args['regroup'] = custom_regroup932 933 args.update(kwargs)934 935 # Detect audio start_time offset from source file (if accessible)936 audio_offset = get_audio_start_time(video_file) if video_file else 0.0937 938 # Perform transcription939 result = model.transcribe(task=task, language=language, **args, verbose=None)940 941 # Apply audio start_time offset to compensate for container timing942 # Whisper ignores silence padding (adelay) from Bazarr, so timestamps943 # are relative to audio stream start, not container start944 if audio_offset > 0:945 apply_timestamp_offset(result, audio_offset)946 947 appendLine(result)948 949 # Set result for blocking endpoint950 if result_container:951 result_container.set_result(result.to_srt_vtt(filepath=None, word_level=word_level_highlight))952 953 except Exception as e:954 logging.error(f"Error processing ASR (ID: {task_id}): {e}", exc_info=True)955 if result_container: 956 result_container.set_error(str(e))957 958 finally:959 delete_model()960 961async def get_audio_chunk(audio_file, offset=detect_language_offset, length=detect_language_length, sample_rate=16000, audio_format=np.int16):962 """963 Extract a chunk of audio from a file, starting at the given offset and of the given length.964 965 :param audio_file: The audio file (UploadFile or file-like object).966 :param offset: The offset in seconds to start the extraction.967 :param length: The length in seconds for the chunk to be extracted.968 :param sample_rate: The sample rate of the audio (default 16000).969 :param audio_format: The audio format to interpret (default int16, 2 bytes per sample).970 971 :return: A numpy array containing the extracted audio chunk.972 """973 974 # Number of bytes per sample (for int16, 2 bytes per sample)975 bytes_per_sample = np.dtype(audio_format).itemsize976 977 # Calculate the start byte based on offset and sample rate978 start_byte = offset * sample_rate * bytes_per_sample979 980 # Calculate the length in bytes based on the length in seconds981 length_in_bytes = length * sample_rate * bytes_per_sample982 983 # Seek to the start position (this assumes the audio_file is a file-like object)984 await audio_file.seek(start_byte)985 986 # Read the required chunk of audio (length_in_bytes)987 chunk = await audio_file.read(length_in_bytes)988 989 # Convert the chunk into a numpy array (normalized to float32)990 audio_data = np.frombuffer(chunk, dtype=audio_format).flatten().astype(np.float32) / 32768.0991 992 return audio_data993 994# ============================================================================995# REFACTORED /DETECT-LANGUAGE ENDPOINT WITH HASH-BASED DEDUPLICATION AND BLOCKING996# ============================================================================997 998@app.post("/detect-language")999async def detect_language(1000 audio_file: UploadFile = File(...),1001 encode: bool = Query(default=True),1002 video_file: Union[str, None] = Query(default=None),1003 detect_lang_length: int = Query(default=detect_language_length),1004 detect_lang_offset: int = Query(default=detect_language_offset)1005):1006 global active_direct_tasks1007 1008 if force_detected_language_to: 1009 await audio_file.close()1010 return {"detected_language": force_detected_language_to.to_name(), "language_code": force_detected_language_to.to_iso_639_1()}1011 1012 task_started = False1013 try:1014 file_content = await audio_file.read()1015 if not file_content:1016 return {"detected_language": "Unknown", "language_code": "und", "status": "error"}1017 1018 logging.info("Immediate language detection (Queue Bypass)" + (f" for {video_file}" if video_file else ""))1019 1020 # Track that we are directly using the model outside the queue1021 with active_direct_tasks_lock:1022 active_direct_tasks += 11023 task_started = True1024 1025 # --- RUN IMMEDIATELY ---1026 # EVENT LOOP BLOCK FIX: Offload heavy ops to background thread1027 await asyncio.to_thread(start_model)1028 1029 if encode:1030 audio_bytes = await asyncio.to_thread(1031 extract_audio_segment_from_content, 1032 file_content, 1033 detect_lang_offset, 1034 detect_lang_length1035 )1036 audio_data = np.frombuffer(audio_bytes, np.int16).flatten().astype(np.float32) / 32768.01037 else:1038 audio_data = await get_audio_chunk(audio_file, detect_lang_offset, detect_lang_length)1039 1040 # Offload the heavy AI inference to a background thread1041 result = await asyncio.to_thread(model.transcribe, audio_data, input_sr=16000, verbose=False)1042 1043 detected = LanguageCode.from_string(result.language)1044 1045 logging.info(f"Detect Language Result: {detected.to_name()} ({detected.to_iso_639_1()})")1046 1047 return {1048 "detected_language": detected.to_name(),1049 "language_code": detected.to_iso_639_1()1050 }1051 1052 except Exception as e: 1053 logging.error(f"Error in API detect-language: {e}", exc_info=True)1054 return {"detected_language": "Unknown", "language_code": "und", "status": "error"}1055 finally: 1056 await audio_file.close()1057 # Decrement counter so delete_model() knows we are done1058 if task_started:1059 with active_direct_tasks_lock:1060 active_direct_tasks -= 11061 delete_model() # Schedules VRAM cleanup if system is idle1062 1063# ============================================================================1064# DETECT LANGUAGE WORKER FOR UPLOADED AUDIO1065# ============================================================================1066 1067def detect_language_from_upload(task_data: dict) -> None:1068 """1069 Worker function that processes detect-language tasks from uploaded audio. 1070 Sets the result in the result_container when complete.1071 """1072 detected_language = LanguageCode.NONE1073 task_id = task_data.get('path', 'unknown')1074 result_container = task_data.get('result_container')1075 1076 try:1077 video_file = task_data.get('video_file')1078 file_content = task_data['audio_content']1079 encode = task_data['encode']1080 detect_lang_length = task_data['detect_lang_length']1081 detect_lang_offset = task_data['detect_lang_offset']1082 1083 logging.info(1084 f"Detecting language for '{video_file}' ({detect_lang_length}s starting at {detect_lang_offset}s) - ID: {task_id}"1085 if video_file1086 else f"Detecting language ({detect_lang_length}s starting at {detect_lang_offset}s) - ID: {task_id}"1087 )1088 1089 start_model()1090 1091 args = {}1092 args['progress_callback'] = None1093 1094 # Handle audio extraction1095 if encode:1096 audio_bytes = extract_audio_segment_from_content(1097 file_content, 1098 detect_lang_offset, 1099 detect_lang_length1100 )1101 args['audio'] = audio_bytes1102 args['input_sr'] = 160001103 else:1104 args['audio'] = np.frombuffer(file_content, np.int16).flatten().astype(np.float32) / 32768.01105 args['input_sr'] = 160001106 1107 args.update(kwargs)1108 args['verbose'] = False # Hide the confusing progress bar1109 1110 result = model.transcribe(**args)1111 detected_language = LanguageCode.from_string(result.language)1112 language_code = detected_language.to_iso_639_1()1113 1114 logging.info(f"Detected language: {detected_language.to_name()} ({language_code}) - ID: {task_id}")1115 1116 # Set the result for the blocking endpoint1117 if result_container:1118 result_container.set_result({1119 "detected_language": detected_language.to_name(),1120 "language_code": language_code1121 })1122 1123 except Exception as e:1124 logging.error(1125 f"Error detecting language (ID: {task_id}) for '{task_data.get('video_file')}': {e}"1126 if task_data.get('video_file')1127 else f"Error detecting language (ID: {task_id}): {e}",1128 exc_info=True1129 )1130 if result_container: 1131 result_container.set_error(str(e))1132 1133 finally:1134 delete_model()1135 1136# ============================================================================1137# HELPER: Extract audio segment from in-memory content1138# ============================================================================1139 1140def extract_audio_segment_from_content(audio_content: bytes, start_time: int, duration: int) -> bytes:1141 """1142 Extract a segment of audio from in-memory content using FFmpeg.1143 1144 Args:1145 audio_content: Raw audio bytes1146 start_time: Start time in seconds1147 duration: Duration in seconds1148 1149 Returns:1150 Audio bytes of the extracted segment1151 """1152 try: 1153 logging.info(f"Extracting audio segment: start_time={start_time}s, duration={duration}s")1154 1155 out, _ = (1156 ffmpeg1157 .input('pipe:0', ss=start_time, t=duration)1158 .output('pipe:1', format='wav', acodec='pcm_s16le', ar=16000)1159 .run(input=audio_content, capture_stdout=True, capture_stderr=True)1160 )1161 1162 if not out:1163 raise ValueError("FFmpeg output is empty")1164 1165 return out1166 1167 except ffmpeg.Error as e:1168 logging.error(f"FFmpeg error: {e.stderr.decode()}")1169 return audio_content # Fallback to original if extraction fails1170 except Exception as e:1171 logging.error(f"Error extracting audio segment: {str(e)}")1172 return audio_content # Fallback to original1173 1174def detect_language_task(path, original_task_data=None):1175 """1176 Worker function that detects language for a local file.1177 Returns the task data to be queued for transcription.1178 """1179 detected_language = LanguageCode.NONE1180 1181 try:1182 logging.info(1183 f"Detecting language of file: {path} "1184 f"({detect_language_length}s starting at {detect_language_offset}s)"1185 )1186 1187 start_model()1188 1189 audio_segment = extract_audio_segment_to_memory(1190 path, 1191 detect_language_offset, 1192 int(detect_language_length)1193 )1194 1195 # FIX: Hide confusing progress bar and use from_string for ISO codes1196 result = model.transcribe(audio_segment, verbose=False)1197 detected_language = LanguageCode.from_string(result.language)1198 1199 logging.info(f"Detected language: {detected_language.to_name()}")1200 