diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index be96bc79db..e178509aae 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -181,6 +181,7 @@ jobs:
tests/bazarr/test_rar_extraction_production_config.py \
tests/bazarr/test_provider_hub_auto_install_optin.py \
tests/bazarr/test_provider_hub_archive_select.py \
+ tests/bazarr/test_provider_hub_archive_candidates.py \
tests/bazarr/test_archive_member_matching.py \
tests/bazarr/test_provider_hub_worker_timeout.py \
tests/bazarr/test_arr_instances_schema.py \
@@ -203,6 +204,7 @@ jobs:
tests/bazarr/test_processing_sync_substep.py \
tests/bazarr/test_mass_download_path_mapping.py \
tests/bazarr/test_processing_postprocessing.py \
+ tests/bazarr/test_upload_background_sync.py \
tests/bazarr/test_sync_progress_report.py \
tests/bazarr/test_subsync_engines.py \
tests/bazarr/test_autosubsync_diagnostics.py \
@@ -276,6 +278,11 @@ jobs:
tests/bazarr/test_jellyfin_client.py \
tests/bazarr/test_jellyfin_operations.py \
tests/bazarr/test_logging_filters.py \
+ tests/bazarr/test_openrouter_provider_routing.py \
+ tests/bazarr/test_openrouter_model_migration.py \
+ tests/bazarr/test_openrouter_translator_partial.py \
+ tests/bazarr/test_openrouter_translator.py \
+ tests/bazarr/test_translator_model_migration.py \
tests/bazarr/test_provider_hub_install_serialization.py \
tests/bazarr/test_provider_hub_language_fallback.py \
tests/bazarr/test_provider_hub_release_scoring.py \
@@ -289,12 +296,14 @@ jobs:
tests/bazarr/test_sonarr_sync_episodes.py \
tests/bazarr/test_sql_profiler.py \
tests/bazarr/test_subtitle_content_sync_outputs.py \
+ tests/bazarr/test_subtitle_destination_path.py \
tests/bazarr/test_subtitle_download_api.py \
tests/bazarr/test_subtitles_pool.py \
tests/bazarr/test_swagger_static.py \
tests/bazarr/test_supervisor_proxy.py \
tests/bazarr/test_supervisor_stages.py \
tests/bazarr/test_tracemalloc_dumper.py \
+ tests/bazarr/test_translate_job_media_name.py \
tests/bazarr/test_translator_auth.py \
tests/bazarr/test_upgrade_embedded_history.py \
tests/subliminal_patch/test_animetosho.py \
@@ -328,6 +337,7 @@ jobs:
tests/bazarr/test_arr_pg_cutover_migration.py \
tests/bazarr/test_editor_instance_scope.py \
tests/bazarr/test_history_blacklist_scope.py \
+ tests/bazarr/test_history_owner_resolution.py \
tests/bazarr/test_local_id_actions.py \
tests/bazarr/test_navigation_local_ids.py \
tests/bazarr/test_provider_blacklist_instance_scope.py \
@@ -350,6 +360,7 @@ jobs:
tests/bazarr/test_indexer_owner_scope.py \
tests/bazarr/test_indexer_path_mapping_owner.py \
tests/bazarr/test_provider_hub_worker_reaper.py \
+ tests/bazarr/test_provider_hub_worker_errors.py \
tests/bazarr/test_job_module_identity.py \
tests/bazarr/test_sonarr_sync_memory.py \
tests/bazarr/test_anilist_index.py \
diff --git a/README.md b/README.md
index 39ae42e709..eb44187df7 100644
--- a/README.md
+++ b/README.md
@@ -246,7 +246,7 @@ Bazarr+ solves it with a **Provider Priority toggle** in Settings > Providers. W
Upstream has Google Translate, Gemini, and Lingarr. Bazarr+ adds **OpenRouter** as a fourth translator engine, giving access to 300+ LLMs (Claude, Gemini, GPT, LLaMA, Grok, and more) plus any custom model ID from openrouter.ai. It runs as a separate microservice with an async job queue supporting 1-5 concurrent jobs and 1-8 parallel batches. Features include:
- **Translate from the subtitle action menu**: click (...) on a missing subtitle row, pick an existing source subtitle to translate from
- **Batch translation** for entire series/movie libraries from the Wanted pages
-- **Dedicated settings page** with 4 zones: engine picker, connection config, model tuning (temperature, reasoning mode, parallel batches), and a live status panel showing queue stats, job progress, token usage, cost, and speed
+- **Dedicated settings page** with 4 zones: engine picker, connection config, model tuning (temperature, reasoning mode, provider routing, parallel batches), and a live status panel showing queue stats, job progress, token usage, cost, and speed
- **Model details** fetched live from the OpenRouter API with per-million token pricing, per-episode/movie cost estimates, context length, and prompt caching indicators
- **AES-256-GCM encryption** for API keys in transit between Bazarr and the translator service, with a Test Connection button that validates encryption and API key status before saving
- **Auto disk scan** triggers Sonarr/Radarr to rescan after translation completes
@@ -455,7 +455,7 @@ The OpenSubtitles.org plugin no longer uses environment variables. Configure its
1. Go to **Settings** > **AI Translator**
2. Select **"AI Subtitle Translator"** as the translator engine
3. Enter your **OpenRouter API Key** (get one at [openrouter.ai/keys](https://openrouter.ai/keys))
-4. Choose your preferred **AI Model** (Google: Gemini 2.5 Flash Lite Preview 09-2025 recommended)
+4. Choose your preferred **AI Model** (`google/gemini-2.5-flash-lite` recommended)
5. Save and test with a manual translation
diff --git a/ai-subtitle-translator b/ai-subtitle-translator
index 1ef643b7a9..16b32b3df3 160000
--- a/ai-subtitle-translator
+++ b/ai-subtitle-translator
@@ -1 +1 @@
-Subproject commit 1ef643b7a93a977879230526655a2a61817e4b6a
+Subproject commit 16b32b3df34476634112f358ecbfeb24393f64b0
diff --git a/bazarr/api/subtitles/content.py b/bazarr/api/subtitles/content.py
index 591a46ded2..88292f8c97 100644
--- a/bazarr/api/subtitles/content.py
+++ b/bazarr/api/subtitles/content.py
@@ -21,6 +21,7 @@
from subtitles.indexer.movies import store_subtitles_movie
from subtitles.indexer.series import store_subtitles
from subtitles.processing import ProcessSubtitlesResult
+from subtitles.tools.subsync_engines import subtitle_mutation, subtitle_write_locks
from utilities.helper import get_target_folder
from utilities.path_mappings import path_mappings
@@ -813,13 +814,6 @@ def _save_subtitle_content(media_type, media_id, language_code, arr_instance_id=
subtitle_path, metadata = result
- # Optimistic locking via ETag (optional but recommended)
- if_match = request.headers.get('If-Match')
- if if_match:
- current_etag = generate_etag(subtitle_path)
- if if_match.strip('"') != current_etag:
- return 'Subtitle file has been modified since last read', 412
-
data = request.get_json()
if not data or 'content' not in data:
return 'Request body must include "content" field', 400
@@ -838,8 +832,19 @@ def _save_subtitle_content(media_type, media_id, language_code, arr_instance_id=
if len(encoded) > MAX_FILE_SIZE:
return f'Content too large ({len(encoded)} bytes, max {MAX_FILE_SIZE})', 413
+ video_path = path_mappings.path_replace_instance(
+ metadata['mediaPath'], metadata.get('arrInstanceId', arr_instance_id), media_type)
try:
- _write_bytes_atomically(subtitle_path, encoded)
+ with subtitle_write_locks(video_path, subtitle_path):
+ if not os.path.isfile(subtitle_path):
+ return 'Subtitle file or directory not found', 404
+ if_match = request.headers.get('If-Match')
+ if if_match and if_match.strip('"') != generate_etag(subtitle_path):
+ return 'Subtitle file has been modified since last read', 412
+ with subtitle_mutation(video_path, subtitle_path):
+ _write_bytes_atomically(subtitle_path, encoded)
+ _apply_subtitle_chmod(subtitle_path)
+ new_etag = generate_etag(subtitle_path)
except FileNotFoundError:
return 'Subtitle file or directory not found', 404
except PermissionError:
@@ -849,11 +854,7 @@ def _save_subtitle_content(media_type, media_id, language_code, arr_instance_id=
return 'No space left on device', 507
raise
- _apply_subtitle_chmod(subtitle_path)
-
_refresh_media_subtitles(media_type, media_id, metadata)
-
- new_etag = generate_etag(subtitle_path)
response = make_response('', 204)
response.headers['ETag'] = f'"{new_etag}"'
return response
@@ -900,15 +901,20 @@ def promote_sync_subtitle(media_type, media_id, target_language, source_language
if os.path.realpath(source_path) == os.path.realpath(target_path):
return 'Source and target subtitles are the same file', 400
+ video_path = path_mappings.path_replace_instance(
+ metadata['mediaPath'], metadata.get('arrInstanceId', arr_instance_id), media_type)
try:
- file_size = os.path.getsize(source_path)
- if file_size > MAX_FILE_SIZE:
- return f'Subtitle file too large ({file_size} bytes, max {MAX_FILE_SIZE})', 413
-
- with open(source_path, 'rb') as source_file:
- data = source_file.read()
-
- _write_bytes_atomically(target_path, data)
+ with subtitle_write_locks(video_path, source_path, target_path):
+ file_size = os.path.getsize(source_path)
+ if file_size > MAX_FILE_SIZE:
+ return f'Subtitle file too large ({file_size} bytes, max {MAX_FILE_SIZE})', 413
+ if not os.path.isfile(target_path):
+ return 'Subtitle file or directory not found', 404
+ with open(source_path, 'rb') as source_file:
+ data = source_file.read()
+ with subtitle_mutation(video_path, target_path):
+ _write_bytes_atomically(target_path, data)
+ _apply_subtitle_chmod(target_path)
except FileNotFoundError:
return 'Subtitle file or directory not found', 404
except PermissionError:
@@ -918,16 +924,17 @@ def promote_sync_subtitle(media_type, media_id, target_language, source_language
return 'No space left on device', 507
raise
- _apply_subtitle_chmod(target_path)
- _log_promoted_sync_history(
- media_type=media_type,
- media_id=media_id,
- target_language=target_language,
- source_language=source_language,
- target_path=target_path,
- metadata=metadata,
- )
- _refresh_media_subtitles(media_type, media_id, metadata)
+ try:
+ _refresh_media_subtitles(media_type, media_id, metadata)
+ finally:
+ _log_promoted_sync_history(
+ media_type=media_type,
+ media_id=media_id,
+ target_language=target_language,
+ source_language=source_language,
+ target_path=target_path,
+ metadata=metadata,
+ )
return {
'sourceLanguage': source_language,
@@ -1096,24 +1103,20 @@ def _create_subtitle(media_type, media_id, arr_instance_id=None):
subtitle_path.startswith(target_folder_real + os.sep)):
return 'Invalid subtitle path', 400
- # Check for existing file
- if os.path.isfile(subtitle_path):
- return 'Subtitle file already exists', 409
-
# Encode content
encoded = content.encode('utf-8')
if len(encoded) > MAX_FILE_SIZE:
return f'Content too large ({len(encoded)} bytes, max {MAX_FILE_SIZE})', 413
- # Write atomically
+ # Check and create under the same mutation lock as replacements.
try:
- fd, tmp_path = tempfile.mkstemp(dir=target_folder)
- try:
- os.write(fd, encoded)
- finally:
- os.close(fd)
+ with subtitle_write_locks(video_path, subtitle_path):
+ if os.path.isfile(subtitle_path):
+ return 'Subtitle file already exists', 409
+ with subtitle_mutation(video_path, subtitle_path):
+ _write_bytes_atomically(subtitle_path, encoded)
+ _apply_subtitle_chmod(subtitle_path)
- os.replace(tmp_path, subtitle_path)
except FileNotFoundError:
return 'Target directory not found', 404
except PermissionError:
@@ -1123,14 +1126,6 @@ def _create_subtitle(media_type, media_id, arr_instance_id=None):
return 'No space left on device', 507
raise
- # Apply chmod if configured
- if settings.general.chmod_enabled:
- try:
- chmod_value = int(settings.general.chmod, 8)
- os.chmod(subtitle_path, chmod_value)
- except Exception:
- pass
-
# Force re-scan subtitles from disk using the media (video) path
if media_type == 'episode':
store_subtitles(row.path, video_path, use_cache=False, arr_instance_id=arr_instance_id)
diff --git a/bazarr/api/translator/translator.py b/bazarr/api/translator/translator.py
index 62f3f42fcc..acd81828cf 100644
--- a/bazarr/api/translator/translator.py
+++ b/bazarr/api/translator/translator.py
@@ -8,6 +8,7 @@
from app.config import settings
from app.jobs_queue import jobs_queue
from subtitles.tools.translate.services.auth import get_translator_auth_headers
+from subtitles.tools.translate.services.openrouter_translator import build_provider_config
from ..utils import authenticate
api_ns_translator = Namespace('Translator', description='AI Subtitle Translator service operations')
@@ -124,6 +125,7 @@ def post(self):
"apiKey": api_key,
"model": settings.translator.openrouter_model,
"temperature": settings.translator.openrouter_temperature,
+ "provider": build_provider_config(),
}
}
diff --git a/bazarr/app/config.py b/bazarr/app/config.py
index ce3aa0a034..ef0669caf4 100644
--- a/bazarr/app/config.py
+++ b/bazarr/app/config.py
@@ -238,12 +238,17 @@ def check_parser_binary(value):
Validator('translator.lingarr_url', must_exist=True, default='http://lingarr:9876', is_type_of=str),
Validator('translator.openrouter_url', must_exist=True, default='http://subtitle-translator:8765', is_type_of=str),
Validator('translator.openrouter_api_key', must_exist=True, default='', is_type_of=str, cast=str),
- Validator('translator.openrouter_model', must_exist=True, default='google/gemini-2.5-flash-preview-05-20', is_type_of=str),
+ Validator('translator.openrouter_model', must_exist=True, default='google/gemini-2.5-flash-lite', is_type_of=str),
Validator('translator.openrouter_temperature', must_exist=True, default=0.3, is_type_of=float),
Validator('translator.openrouter_max_concurrent', must_exist=True, default=2, is_type_of=int, gte=1, lte=10),
Validator('translator.openrouter_reasoning', must_exist=True, default='disabled', is_type_of=str,
is_in=['disabled', 'low', 'medium', 'high']),
Validator('translator.openrouter_parallel_batches', must_exist=True, default=4, is_type_of=int, gte=1, lte=8),
+ # Which OpenRouter provider serves the model: throughput (the sidecar's historical default),
+ # nitro/floor (OpenRouter's slug shortcuts, which also unlock the priority/flex tiers),
+ # price, latency, or OpenRouter's own load balancing.
+ Validator('translator.openrouter_provider_routing', must_exist=True, default='throughput', is_type_of=str,
+ is_in=['throughput', 'nitro', 'price', 'floor', 'latency', 'default']),
Validator('translator.openrouter_encryption_key', must_exist=True, default='', is_type_of=str, cast=str),
Validator('translator.lingarr_token', must_exist=True, default='', is_type_of=str, cast=str),
@@ -727,6 +732,26 @@ def write_config():
_force_first_save_migration = False
+# OpenRouter retired these ids, including Bazarr's default and documented recommendation.
+# Users who never chose a model would otherwise get a model-not-found error.
+RETIRED_OPENROUTER_MODELS = {
+ 'google/gemini-2.5-flash-preview-05-20': 'google/gemini-2.5-flash',
+ 'google/gemini-2.5-flash-lite-preview-06-17': 'google/gemini-2.5-flash-lite',
+ 'google/gemini-2.5-flash-lite-preview-09-2025': 'google/gemini-2.5-flash-lite',
+ 'google/gemini-3-pro-preview': 'google/gemini-3.1-pro-preview',
+}
+
+
+def migrate_retired_openrouter_model(settings) -> bool:
+ current_model = settings.translator.openrouter_model.strip()
+ replacement = RETIRED_OPENROUTER_MODELS.get(current_model)
+ if replacement is None:
+ return False
+ settings.translator.openrouter_model = replacement
+ logging.warning(f'Replaced retired OpenRouter model {current_model} with {replacement}') # noqa: G004
+ return True
+
+
base_url = settings.general.base_url.rstrip('/')
array_keys = ['excluded_tags',
@@ -790,6 +815,8 @@ def write_config():
settings.translator.gemini_keys = [legacy_key]
del settings.translator.gemini_key
+migrate_retired_openrouter_model(settings)
+
# save updated settings to file
write_config()
diff --git a/bazarr/app/requirements.py b/bazarr/app/requirements.py
index 105b1bf49b..65773c7f4f 100644
--- a/bazarr/app/requirements.py
+++ b/bazarr/app/requirements.py
@@ -6,7 +6,6 @@
import logging
import os
from pathlib import Path
-import re
import subprocess
import sys
@@ -95,21 +94,21 @@
)
RUNTIME_REQUIREMENTS = {
- "setuptools": ("setuptools", ">=82.0.1"),
- "aiohttp": ("aiohttp", ">=3.13.5"),
- "alembic": ("alembic", "==1.18.4"),
+ "setuptools": ("setuptools", ">=84.0.0"),
+ "aiohttp": ("aiohttp", ">=3.14.3"),
+ "alembic": ("alembic", "==1.19.1"),
"apprise": ("apprise", "==1.13.0"),
- "apscheduler": ("apscheduler", "==3.11.2"),
+ "apscheduler": ("apscheduler", "==3.11.3"),
"babelfish": ("babelfish", "==0.6.1"),
- "bs4": ("beautifulsoup4", "==4.14.3"),
- "cachetools": ("cachetools", ">=7.1.1"),
+ "bs4": ("beautifulsoup4", "==4.15.0"),
+ "cachetools": ("cachetools", ">=7.1.4"),
"certifi": ("certifi", "==2026.7.22"),
- "chardet": ("chardet", "==7.4.3"),
+ "chardet": ("chardet", "==7.6.0"),
"charset_normalizer": ("charset-normalizer", "==3.5.1"),
- "click_option_group": ("click-option-group", ">=0.5.6"),
- "cloudscraper": ("cloudscraper", "<=1.2.58"),
- "cryptography": ("cryptography", ">=48.0.0"),
- "dateutil": ("python-dateutil", "==2.9.0"),
+ "click_option_group": ("click-option-group", ">=0.5.9"),
+ "cloudscraper": ("cloudscraper", "<=1.2.71"),
+ "cryptography": ("cryptography", ">=50.0.1"),
+ "dateutil": ("python-dateutil", "==2.9.0.post0"),
"deathbycaptcha": ("deathbycaptcha-official", "==4.7.1"),
"deep_translator": ("deep-translator", "==1.11.4"),
"dns": ("dnspython", "==2.8.0"),
@@ -120,32 +119,32 @@
"fcache": ("fcache", "==0.6.0"),
"fese": ("fese", "==0.3.0"),
"ffmpeg": ("ffmpeg-python", "==0.2.0"),
- "ffsubsync": ("ffsubsync", "==0.4.31"),
+ "ffsubsync": ("ffsubsync", "==0.5.0"),
"autosubsync": ("autosubsync", "==1.0.1"),
"filetype": ("filetype", "==1.2.0"),
"flask": ("Flask", "==3.1.3"),
"flask_compress": ("Flask-Compress", "==1.24"),
- "flask_cors": ("flask-cors", "==6.0.2"),
+ "flask_cors": ("flask-cors", "==6.0.5"),
"flask_migrate": ("Flask-Migrate", "==4.1.0"),
"flask_restx": ("flask-restx", "==1.3.2"),
"flask_socketio": ("Flask-SocketIO", "==5.6.1"),
"flask_sqlalchemy": ("flask_sqlalchemy", "==3.1.1"),
"ftfy": ("ftfy", "==6.3.1"),
- "guess_language": ("guess_language-spirit", "==0.5.3"),
- "guessit": ("guessit", "==3.8.0"),
+ "guess_language": ("guess_language-spirit", "==0.5.4"),
+ "guessit": ("guessit", "==4.4.0"),
"itsdangerous": ("itsdangerous", "==2.2.0"),
- "jwt": ("PyJWT", ">=2.12.1"),
+ "jwt": ("PyJWT", ">=2.13.0"),
"json_tricks": ("json_tricks", "==3.17.3"),
- "knowit": ("knowit", "==0.5.11"),
- "lxml": ("lxml", ">=6.1.0"),
+ "knowit": ("knowit", "==0.6.1"),
+ "lxml": ("lxml", ">=6.1.1"),
"msgpack": ("msgpack", "==1.2.1"), # signalrcore over-pins ==1.1.2; we install signalrcore --no-deps
- "numpy": ("numpy", ">=2.0.0,<2.4.0"),
- "PIL": ("Pillow", ">=12.2.0"),
- "plexapi": ("plexapi", ">=4.16.1"),
+ "numpy": ("numpy", ">=2.5.2,<2.6.0"),
+ "PIL": ("Pillow", ">=12.3.0"),
+ "plexapi": ("plexapi", ">=4.18.2"),
"py7zr": ("py7zr", "==1.1.3"),
"pycountry": ("pycountry", "==26.2.16"),
"pysrt": ("pysrt", "==1.1.2"),
- "pysubs2": ("pysubs2", "==1.8.0"),
+ "pysubs2": ("pysubs2", "==1.8.1"),
"python_anticaptcha": ("python-anticaptcha", "==2.0.0"),
"rarfile": ("rarfile", "==4.5"),
"requests": ("requests", "==2.34.2"),
@@ -153,7 +152,7 @@
"semver": ("semver", "==3.0.4"),
"signalrcore": ("signalrcore", "==1.0.2"),
"six": ("six", "==1.17.0"),
- "sqlalchemy": ("sqlalchemy", "==2.0.49"),
+ "sqlalchemy": ("sqlalchemy", "==2.0.52"),
"srt": ("srt", "==3.5.3"),
"subliminal": ("subliminal", "==2.6.0"),
"textdistance": ("textdistance", "==4.6.3"),
@@ -170,8 +169,8 @@
}
WINDOWS_RUNTIME_REQUIREMENTS = {
- "win32api": ("pywin32", ">=311"),
- "win32con": ("pywin32", ">=311"),
+ "win32api": ("pywin32", ">=312"),
+ "win32con": ("pywin32", ">=312"),
}
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -199,30 +198,32 @@ def is_virtualenv():
return base_prefix != real_prefix
-def _version_tuple(version):
- parts = re.findall(r"\d+", version.split("+", 1)[0].split("-", 1)[0])
- return tuple(int(part) for part in parts)
-
-
def _satisfies_spec(installed_version, spec):
- installed = _version_tuple(installed_version)
- for item in spec.split(","):
- item = item.strip()
- if item.startswith("=="):
- if installed != _version_tuple(item[2:]):
- return False
- elif item.startswith(">="):
- if installed < _version_tuple(item[2:]):
- return False
- elif item.startswith("<="):
- if installed > _version_tuple(item[2:]):
- return False
- elif item.startswith("<"):
- if installed >= _version_tuple(item[1:]):
- return False
- else:
- raise ValueError(f"Unsupported requirement specifier: {item}")
- return True
+ # Keep startup and --no-update usable before dependencies are installed.
+ try:
+ from packaging.specifiers import SpecifierSet
+ from packaging.version import InvalidVersion, Version
+ except ModuleNotFoundError as error:
+ if error.name not in ("packaging", "packaging.specifiers", "packaging.version"):
+ raise
+ try:
+ from pip._vendor.packaging.specifiers import SpecifierSet
+ from pip._vendor.packaging.version import InvalidVersion, Version
+ except ModuleNotFoundError as error:
+ if error.name not in ("pip", "pip._vendor", "pip._vendor.packaging",
+ "pip._vendor.packaging.specifiers", "pip._vendor.packaging.version"):
+ raise
+ logging.warning("BAZARR cannot verify runtime versions because packaging and pip's parser are unavailable.")
+ return False
+
+ requirement = SpecifierSet(spec)
+ try:
+ installed = Version(installed_version)
+ except InvalidVersion:
+ return False
+ # Already-installed prereleases remain eligible, but must satisfy the full
+ # version bounds. Do not rely on packaging's changing prerelease defaults.
+ return requirement.contains(installed, prereleases=True)
def _module_origin(module):
diff --git a/bazarr/provider_hub/protocol.py b/bazarr/provider_hub/protocol.py
index 6640f386f4..dc5bce1ad9 100644
--- a/bazarr/provider_hub/protocol.py
+++ b/bazarr/provider_hub/protocol.py
@@ -5,12 +5,15 @@
import hashlib
import logging
import os
+import re
+import stat
from typing import Any
from subzero.language import Language
from subliminal.video import Episode, Movie
from subliminal_patch.core import SUBTITLE_EXTENSIONS
+from subliminal_patch.exceptions import SubtitleCandidateRejected
from subliminal_patch.subtitle import Subtitle
logger = logging.getLogger(__name__)
@@ -320,6 +323,8 @@ def _format_from_member(name: str | None, content: bytes | None = None) -> str |
def worker_download_to_content(
subtitle: HubWorkerSubtitle, payload: dict[str, Any], select_member_cb=None
) -> bool:
+ if not isinstance(payload, dict):
+ raise WorkerProtocolError("download must return an object")
if payload.get("empty"):
subtitle.content = b""
return True
@@ -371,6 +376,11 @@ def _guard_archive_members(archive) -> None:
raise WorkerProtocolError("download.archive_b64 contains too many members")
total = 0
for info in infos:
+ is_link = getattr(info, "is_symlink", False)
+ if callable(is_link):
+ is_link = is_link()
+ if is_link or stat.S_ISLNK(getattr(info, "external_attr", 0) >> 16):
+ raise WorkerProtocolError("download.archive_b64 contains a symbolic link")
# zip/rar expose file_size; py7zr exposes uncompressed.
size = int(getattr(info, "file_size", 0) or getattr(info, "uncompressed", 0) or 0)
if size > _MAX_MEMBER_BYTES:
@@ -378,6 +388,40 @@ def _guard_archive_members(archive) -> None:
total += size
if total > _MAX_ARCHIVE_TOTAL_BYTES:
raise WorkerProtocolError("download.archive_b64 decompresses past the size limit")
+ for name in archive.namelist():
+ _validate_archive_member_name(name, disk_extracted=isinstance(archive, _SevenZipArchive))
+
+
+def _validate_archive_member_name(name, disk_extracted=False):
+ if not isinstance(name, str) or not name:
+ raise WorkerProtocolError("archive member must be a non-empty string")
+ normalized = name.replace("\\", "/")
+ if (normalized.startswith("/") or ".." in normalized.split("/")
+ or re.match(r"^[A-Za-z]:", normalized) or "\0" in normalized
+ or (disk_extracted and ":" in normalized)):
+ raise WorkerProtocolError("archive member has an unsafe path")
+
+
+def _archive_rejection(subtitle, archive, reason):
+ """Describe ordinary mismatch without retaining paths, URLs or archive bytes."""
+ def safe_label(value, limit):
+ return re.sub(r"[^a-zA-Z0-9_. ()-]", "_", str(value or ""))[:limit]
+
+ members = []
+ for name in archive.namelist()[:8]:
+ basename = name.replace("\\", "/").rsplit("/", 1)[-1]
+ basename = basename.split("?", 1)[0].split("#", 1)[0]
+ members.append(safe_label(basename, 80))
+ identifier = str(getattr(subtitle, "worker_id", ""))
+ language = getattr(subtitle, "language", None)
+ return SubtitleCandidateRejected(
+ f"{reason}; provider={safe_label(getattr(subtitle, 'provider_name', ''), 48)} "
+ f"result_sha256={hashlib.sha256(identifier.encode()).hexdigest()[:16]} "
+ f"season={safe_label(getattr(subtitle, 'season', None), 12)} "
+ f"episode={safe_label(getattr(subtitle, 'episode', None), 12)} "
+ f"language={safe_label(getattr(language, 'basename', None), 24)} "
+ f"archive={type(archive).__name__} members={members!r}"
+ )
def _list_archive_members(archive):
@@ -516,50 +560,81 @@ def _worker_archive_to_content(
if archive is None:
raise WorkerProtocolError("download.archive_b64 is not a zip, rar, or 7z archive")
_guard_archive_members(archive)
+ for flag in ("select_member", "first_subtitle"):
+ if flag in payload and not isinstance(payload[flag], bool):
+ raise WorkerProtocolError(f"download.{flag} must be a boolean")
+ episode = payload.get("episode")
+ episodes = episode if isinstance(episode, (list, tuple)) else [episode]
+ if any(value is not None and (type(value) is not int or value < 0) for value in episodes):
+ raise WorkerProtocolError("download.episode must contain non-negative integers")
+ season = payload.get("season")
+ if season is not None and (type(season) is not int or season < 0):
+ raise WorkerProtocolError("download.season must be a non-negative integer")
+ member = payload.get("member")
+ if member is not None:
+ _validate_archive_member_name(member, disk_extracted=isinstance(archive, _SevenZipArchive))
def _episode_pick():
forced = bool(getattr(getattr(subtitle, "language", None), "forced", False))
- episode = payload.get("episode")
- if isinstance(episode, (list, tuple)):
- episode = episode[0] if episode else None
+ context = getattr(subtitle, "_requested_archive_context", None)
+ if context is None:
+ context_season = getattr(subtitle, "season", None)
+ if context_season is None:
+ context_season = season
+ episode = payload.get("episode")
+ if isinstance(episode, (list, tuple)):
+ episode = episode[0] if episode else None
+ else:
+ # An absent requested number is intentional, including movie searches.
+ context_season = context.get("season")
+ requested_episodes = (context.get("episode"), context.get("absolute_episode"))
+ episode = tuple(value for value in requested_episodes if type(value) is int and value >= 0) or None
picked = get_subtitle_from_archive(
archive,
forced=forced,
episode=episode,
+ season=context_season,
episode_title=payload.get("episode_title"),
get_first_subtitle=bool(payload.get("first_subtitle")),
extensions=ARCHIVE_MEMBER_EXTENSIONS,
+ match_episode_context=True,
+ log_member_names=False,
)
if picked is None:
- raise WorkerProtocolError("download.archive_b64 has no usable subtitle member")
+ raise _archive_rejection(subtitle, archive, "No usable subtitle member")
return picked
- member = payload.get("member")
- if member:
+ if member is not None:
if member not in set(archive.namelist()):
- raise WorkerProtocolError(f"download.member is not in the archive: {member}")
+ raise _archive_rejection(subtitle, archive, "Pinned subtitle member is absent")
content = fix_line_ending(archive.read(member))
chosen = member
- elif payload.get("select_member") and select_member_cb is not None:
+ elif payload.get("select_member"):
# Host lists the members; the worker language-pins one (tri-state pin/defer/reject).
# This is the only way to language-select rar/7z, which the stdlib-only worker
- # cannot list. "defer" => safe episode pick (single-language); "reject" => fail loud.
- result = select_member_cb(_list_archive_members(archive)) or {}
+ # cannot list. "defer" => episode pick; "reject" => skip this candidate.
+ if select_member_cb is None:
+ raise WorkerProtocolError("download.select_member requires a selector")
+ offered_members = tuple(_list_archive_members(archive))
+ result = select_member_cb(list(offered_members))
+ if not isinstance(result, dict):
+ raise WorkerProtocolError("select_archive_member must return an object")
decision = result.get("decision")
+ if decision in ("defer", "reject") and result.get("member") is not None:
+ raise WorkerProtocolError("select_archive_member may only name a member when pinning")
if decision == "pin":
chosen = result.get("member")
- if chosen not in set(archive.namelist()):
- raise WorkerProtocolError(
- f"select_archive_member returned a member not in the archive: {chosen!r}"
- )
+ _validate_archive_member_name(chosen, disk_extracted=isinstance(archive, _SevenZipArchive))
+ if chosen not in offered_members:
+ raise WorkerProtocolError("select_archive_member pinned a member outside the offered subtitles")
content = fix_line_ending(archive.read(chosen))
elif decision == "defer":
content = _episode_pick()
chosen = payload.get("filename") or ""
+ elif decision == "reject":
+ raise _archive_rejection(subtitle, archive, "No matching subtitle language")
else:
- raise WorkerProtocolError(
- "select_archive_member rejected the archive (no language match)"
- )
+ raise WorkerProtocolError("select_archive_member returned an invalid decision")
else:
content = _episode_pick()
chosen = payload.get("filename") or ""
diff --git a/bazarr/provider_hub/registry.py b/bazarr/provider_hub/registry.py
index a82e79d80e..f77fb4fd4c 100644
--- a/bazarr/provider_hub/registry.py
+++ b/bazarr/provider_hub/registry.py
@@ -139,10 +139,18 @@ def list_subtitles(self, video, languages):
"languages": [language_to_payload(item) for item in languages],
}
result = self._worker().request("search", request, timeout=timeout)
- return [
- candidate_from_worker(self.provider_name, item)
- for item in result.payload.get("candidates", [])
- ]
+ subtitles = []
+ for item in result.payload.get("candidates", []):
+ subtitle = candidate_from_worker(self.provider_name, item)
+ # Request context belongs to this candidate, separate from provider
+ # display metadata and opaque payloads used for scoring and download.
+ subtitle._requested_archive_context = {
+ "season": request["video"]["season"],
+ "episode": request["video"]["episode"],
+ "absolute_episode": request["video"]["absolute_episode"],
+ }
+ subtitles.append(subtitle)
+ return subtitles
def download_subtitle(self, subtitle):
timeout = self._request_timeout()
@@ -155,16 +163,22 @@ def download_subtitle(self, subtitle):
result = self._worker().request("download", request, timeout=timeout)
def _select_member_cb(members):
+ context = getattr(subtitle, "_requested_archive_context", {})
+ selector_payload = {
+ "provider": self.provider_name,
+ "provider_payload": subtitle.provider_payload,
+ "language": language_to_payload(subtitle.language),
+ "members": members,
+ "season": context.get("season", getattr(subtitle, "season", None)),
+ "episode": context.get("episode", getattr(subtitle, "episode", None)),
+ "config": self.config,
+ }
+ if "absolute_episode" in context:
+ selector_payload["absolute_episode"] = context["absolute_episode"]
+ elif hasattr(subtitle, "absolute_episode"):
+ selector_payload["absolute_episode"] = subtitle.absolute_episode
response = self._worker().select_archive_member(
- {
- "provider": self.provider_name,
- "provider_payload": subtitle.provider_payload,
- "language": language_to_payload(subtitle.language),
- "members": members,
- "season": getattr(subtitle, "season", None),
- "episode": getattr(subtitle, "episode", None),
- "config": self.config,
- },
+ selector_payload,
timeout=timeout,
)
return response.payload
diff --git a/bazarr/provider_hub/worker.py b/bazarr/provider_hub/worker.py
index 1b281536c6..30736615f1 100644
--- a/bazarr/provider_hub/worker.py
+++ b/bazarr/provider_hub/worker.py
@@ -53,6 +53,53 @@ def _json_default(obj):
class WorkerError(RuntimeError):
"""Raised when a provider worker fails or returns an error."""
+ def __init__(self, message, *, remote_class_name=None, code=None, retryable=False):
+ super().__init__(message)
+ self.remote_class_name = remote_class_name
+ self.code = code
+ self.retryable = retryable
+
+
+def _raise_worker_error(payload):
+ if not isinstance(payload, dict):
+ raise WorkerError("worker request failed")
+
+ remote_name = payload.get("class_name")
+ remote_name = remote_name if isinstance(remote_name, str) else None
+ code = payload.get("code")
+ code = code if isinstance(code, str) else None
+ retryable = payload.get("retryable", False)
+ retryable = retryable if isinstance(retryable, bool) else False
+ message = payload.get("message")
+ message = message if isinstance(message, str) and message else code or "worker request failed"
+ error = WorkerError(message, remote_class_name=remote_name, code=code, retryable=retryable)
+ if code != "provider":
+ raise error
+
+ # Keep host exception imports out of worker startup. Only these fixed
+ # semantic names can cross the boundary as host provider exceptions.
+ from subliminal.exceptions import (
+ AuthenticationError,
+ ConfigurationError,
+ DownloadLimitExceeded,
+ ServiceUnavailable,
+ )
+ from subliminal_patch.exceptions import APIThrottled, TooManyRequests
+
+ exception_type = {
+ "DownloadLimitExceeded": DownloadLimitExceeded,
+ "TooManyRequests": TooManyRequests,
+ "RateLimited": TooManyRequests,
+ "ServiceUnavailable": ServiceUnavailable,
+ "APIThrottled": APIThrottled,
+ "AuthenticationError": AuthenticationError,
+ "AuthenticationRequired": AuthenticationError,
+ "ConfigurationError": ConfigurationError,
+ }.get(remote_name)
+ if exception_type is None:
+ raise error
+ raise exception_type(message) from error
+
@dataclass
class WorkerResult:
@@ -460,15 +507,15 @@ def request(self, op: str, payload: dict[str, Any] | None = None, timeout: float
except json.JSONDecodeError as error:
raise WorkerError("worker returned malformed JSON") from error
+ if not isinstance(response, dict):
+ raise WorkerError("worker response must be an object")
if response.get("abi") != WORKER_ABI_VERSION:
raise WorkerError("worker returned unsupported ABI")
if response.get("id") != request_id:
raise WorkerError("worker returned mismatched request id")
if not response.get("ok", False):
- error = response.get("error") or {}
- message = error.get("message") or error.get("code") or "worker request failed"
- raise WorkerError(str(message))
+ _raise_worker_error(response.get("error"))
payload = response.get("payload") or {}
events = response.get("events") or []
diff --git a/bazarr/provider_hub/worker_runner.py b/bazarr/provider_hub/worker_runner.py
index 1bafe5c4b6..b78e953b93 100644
--- a/bazarr/provider_hub/worker_runner.py
+++ b/bazarr/provider_hub/worker_runner.py
@@ -78,25 +78,26 @@ def _handle(provider, op, payload):
if op == "select_archive_member":
selector = getattr(provider, "select_archive_member", None)
if selector is None:
- # Provider asked the host to list members (select_member) but exposes no
- # selector: reject so the host fails loud rather than risk a wrong member.
- return {"member": None, "decision": "reject"}
+ raise ValueError("select_archive_member is not implemented")
provider_payload = dict(payload.get("provider_payload") or {})
- # The host forwards the requested season/episode at the top level of the op payload.
+ # The host forwards relative/absolute episode context at the top level of the op payload.
# Surface them on provider_payload (host context is authoritative) so a selector can
# disambiguate season-pack members even when the search payload didn't carry them.
- for key in ("season", "episode"):
- if payload.get(key) is not None:
- provider_payload[key] = payload.get(key)
+ # An explicit null clears stale context; an absent key preserves legacy payloads.
+ for key in ("season", "episode", "absolute_episode"):
+ if key in payload:
+ provider_payload[key] = payload[key]
result = selector(
provider_payload=provider_payload,
language=payload.get("language") or {},
members=payload.get("members") or [],
config=payload.get("config") or {},
- ) or {}
+ )
+ if not isinstance(result, dict):
+ raise ValueError("select_archive_member must return an object")
decision = result.get("decision")
if decision not in ("pin", "defer", "reject"):
- decision = "reject"
+ raise ValueError("select_archive_member returned an invalid decision")
return {"member": result.get("member"), "decision": decision}
raise ValueError(f"unsupported worker op: {op}")
diff --git a/bazarr/radarr/history.py b/bazarr/radarr/history.py
index 17f67c0c70..a05fb6e38e 100644
--- a/bazarr/radarr/history.py
+++ b/bazarr/radarr/history.py
@@ -65,10 +65,11 @@ def history_log_movie(action, radarr_id, result, fake_provider=None, fake_score=
upgradedFromId=upgraded_from_id,
)
- # Stamp owner + local ref (movie_id -> the local PK) from the movie row so
- # history is instance-owned (the INC4 pattern for history). Guarded; NULL
- # for an unresolved row or pre-backfill install. No-op for the default
- # instance beyond populating movie_id.
+ if arr_instance_id is not None:
+ values['arr_instance_id'] = arr_instance_id
+
+ # Resolve the local ref when the movie is available. A missing movie must
+ # not discard an owner supplied by the caller.
if radarr_id is not None:
mv = _resolve_movie_owner_row(radarr_id, arr_instance_id)
if mv is not None:
@@ -78,5 +79,11 @@ def history_log_movie(action, radarr_id, result, fake_provider=None, fake_score=
if mv.id is not None:
values['movie_id'] = mv.id
+ if values.get('arr_instance_id') is None:
+ logging.warning(
+ 'BAZARR skipping movie history insert: unable to resolve owning instance '
+ 'for radarrId %s', radarr_id)
+ return
+
database.execute(insert(TableHistoryMovie).values(**values))
event_stream(type='movie-history')
diff --git a/bazarr/sonarr/history.py b/bazarr/sonarr/history.py
index 49343f5965..439337f5c7 100644
--- a/bazarr/sonarr/history.py
+++ b/bazarr/sonarr/history.py
@@ -68,11 +68,11 @@ def history_log(action, sonarr_series_id, sonarr_episode_id, result, fake_provid
upgradedFromId=upgraded_from_id,
)
- # Stamp the owning instance + local refs (episode_id/series_id -> the local
- # PKs) from the episode row so history is instance-owned, the same way INC4
- # stamps media. Guarded: a row that can't be resolved (or a pre-backfill
- # install) leaves the columns NULL. No-op for the default single instance
- # beyond populating the local refs.
+ if arr_instance_id is not None:
+ values['arr_instance_id'] = arr_instance_id
+
+ # Resolve local refs when the episode is available. A missing episode must
+ # not discard an owner supplied by the caller.
if sonarr_episode_id is not None:
ep = _resolve_episode_owner_row(sonarr_episode_id, arr_instance_id)
if ep is not None:
@@ -84,5 +84,11 @@ def history_log(action, sonarr_series_id, sonarr_episode_id, result, fake_provid
if ep.series_id is not None:
values['series_id'] = ep.series_id
+ if values.get('arr_instance_id') is None:
+ logging.warning(
+ 'BAZARR skipping episode history insert: unable to resolve owning instance '
+ 'for sonarrEpisodeId %s', sonarr_episode_id)
+ return
+
database.execute(insert(TableHistory).values(**values))
event_stream(type='episode-history')
diff --git a/bazarr/subtitles/download.py b/bazarr/subtitles/download.py
index 26b93110ff..8e3a3fd28b 100644
--- a/bazarr/subtitles/download.py
+++ b/bazarr/subtitles/download.py
@@ -4,11 +4,13 @@
import os
import sys
import logging
+from functools import partial
import subliminal
import ast
from subzero.language import Language
from subliminal_patch.core import save_subtitles
+from subtitles.tools.subsync_engines import subtitle_write_locks, subtitle_mutation, write_subtitle_file
from subliminal_patch.core_persistent import download_best_subtitles
from app.config import settings
@@ -149,21 +151,29 @@ def _on_provider(provider_name):
fld = get_target_folder(path)
chmod = int(settings.general.chmod, 8) if not sys.platform.startswith(
'win') and settings.general.chmod_enabled else None
- if is_upgrade and previous_subtitles_to_delete:
- try:
- # delete previously downloaded subtitles in case of an upgrade to prevent edge loop
- # issue.
- os.remove(previous_subtitles_to_delete)
- except (OSError, FileNotFoundError):
- pass
- saved_subtitles = save_subtitles(video.original_path, subtitles,
- single=settings.general.single_language,
- tags=None, # fixme
- directory=fld,
- chmod=chmod,
- formats=subtitle_formats,
- path_decoder=force_unicode
- )
+ with subtitle_write_locks(path, os.path.join(fld or os.path.dirname(path), '.destination'),
+ previous_subtitles_to_delete or path):
+ written_paths = []
+ saved_subtitles = save_subtitles(video.original_path, subtitles,
+ single=settings.general.single_language,
+ tags=None, # fixme
+ directory=fld,
+ chmod=chmod,
+ formats=subtitle_formats,
+ path_decoder=force_unicode,
+ write_subtitle=partial(write_subtitle_file, path, written_paths=written_paths)
+ )
+ if is_upgrade and previous_subtitles_to_delete and written_paths and (
+ os.path.normcase(os.path.realpath(previous_subtitles_to_delete)) not in
+ {os.path.normcase(os.path.realpath(written)) for written in written_paths}):
+ try:
+ with subtitle_mutation(path, previous_subtitles_to_delete):
+ os.remove(previous_subtitles_to_delete)
+ except OSError:
+ logging.exception('BAZARR unable to remove superseded subtitle: %s',
+ previous_subtitles_to_delete)
+ saved_subtitles = [saved for saved in saved_subtitles if saved.storage_path in written_paths]
+
except Exception as e:
logging.exception(
f'BAZARR Error saving Subtitles file to disk for this file {path}: {repr(e)}') # noqa: G004
diff --git a/bazarr/subtitles/indexer/movies.py b/bazarr/subtitles/indexer/movies.py
index 3ae8820071..500604d843 100644
--- a/bazarr/subtitles/indexer/movies.py
+++ b/bazarr/subtitles/indexer/movies.py
@@ -13,7 +13,8 @@
get_audio_profile_languages, database, update, select
from languages.get_languages import alpha2_from_alpha3, get_language_set
from app.config import settings
-from utilities.helper import get_subtitle_destination_folder
+from utilities.helper import get_subtitle_destination_folder, get_target_folder
+from subtitles.tools.subsync_engines import SyncOutputOwnerIndex, subtitle_write_locks
from utilities.path_mappings import path_mappings
from utilities.video_analyzer import embedded_subs_reader
from app.event_handler import event_stream
@@ -31,7 +32,7 @@
gc.enable()
-def store_subtitles_movie(original_path, reversed_path, use_cache=True, arr_instance_id=None):
+def store_subtitles_movie(original_path, reversed_path, use_cache=True, arr_instance_id=None, ownership_index=None):
logging.debug(f'BAZARR started subtitles indexing for this file: {reversed_path}') # noqa: G004
actual_subtitles = []
# The owning instance decides everything below: which per-instance
@@ -102,87 +103,91 @@ def _prr(p):
f"BAZARR error when trying to analyze this {os.path.splitext(reversed_path)[1]} file: " # noqa: G004
f"{reversed_path}")
- try:
- dest_folder = get_subtitle_destination_folder()
- core.CUSTOM_PATHS = [dest_folder] if dest_folder else []
-
- # get previously indexed subtitles that haven't changed:
- item = database.execute(
- scoped(select(TableMovies.subtitles)
- .where(TableMovies.path == original_path),
- TableMovies.arr_instance_id, owner_instance_id))\
- .first()
- if not item:
- previously_indexed_subtitles_to_exclude = []
- else:
- previously_indexed_subtitles = ast.literal_eval(item.subtitles) if item.subtitles else []
- previously_indexed_subtitles_to_exclude = [x for x in previously_indexed_subtitles
- if len(x) == 3 and
- x[1] and
- os.path.isfile(_pr(x[1])) and
- os.stat(_pr(x[1])).st_size == x[2]]
-
- subtitles = search_external_subtitles(reversed_path, languages=get_language_set(),
- only_one=settings.general.single_language)
- full_dest_folder_path = os.path.dirname(reversed_path)
- if dest_folder:
- if settings.general.subfolder == "absolute":
- full_dest_folder_path = dest_folder
- elif settings.general.subfolder == "relative":
- full_dest_folder_path = os.path.join(os.path.dirname(reversed_path), dest_folder)
- subtitles = add_sync_engine_outputs(full_dest_folder_path, subtitles)
- subtitles = add_combined_outputs(full_dest_folder_path, subtitles,
- video_filename=os.path.basename(reversed_path))
- subtitles = guess_external_subtitles(full_dest_folder_path, subtitles, "movie",
- previously_indexed_subtitles_to_exclude)
- except Exception as e:
- logging.exception(f"BAZARR unable to index external subtitles for this file {reversed_path}: {repr(e)}") # noqa: G004
- else:
- for subtitle, language in subtitles.items():
- valid_language = False
- if language:
- if hasattr(language, 'alpha3'):
- valid_language = alpha2_from_alpha3(language.alpha3)
+ destination = os.path.join(get_target_folder(reversed_path, create=False) or os.path.dirname(reversed_path),
+ '.destination')
+ with subtitle_write_locks(reversed_path, destination):
+ try:
+ dest_folder = get_subtitle_destination_folder()
+ core.CUSTOM_PATHS = [dest_folder] if dest_folder else []
+
+ # get previously indexed subtitles that haven't changed:
+ item = database.execute(
+ scoped(select(TableMovies.subtitles)
+ .where(TableMovies.path == original_path),
+ TableMovies.arr_instance_id, owner_instance_id))\
+ .first()
+ if not item:
+ previously_indexed_subtitles_to_exclude = []
else:
- logging.debug(f"Skipping subtitles because we are unable to define language: {subtitle}") # noqa: G004
- continue
+ previously_indexed_subtitles = ast.literal_eval(item.subtitles) if item.subtitles else []
+ previously_indexed_subtitles_to_exclude = [x for x in previously_indexed_subtitles
+ if len(x) == 3 and
+ x[1] and
+ os.path.isfile(_pr(x[1])) and
+ os.stat(_pr(x[1])).st_size == x[2]]
+
+ subtitles = search_external_subtitles(reversed_path, languages=get_language_set(),
+ only_one=settings.general.single_language)
+ full_dest_folder_path = os.path.dirname(reversed_path)
+ if dest_folder:
+ if settings.general.subfolder == "absolute":
+ full_dest_folder_path = dest_folder
+ elif settings.general.subfolder == "relative":
+ full_dest_folder_path = os.path.join(os.path.dirname(reversed_path), dest_folder)
+ subtitles = add_sync_engine_outputs(full_dest_folder_path, subtitles,
+ video_path=reversed_path, ownership_index=ownership_index)
+ subtitles = add_combined_outputs(full_dest_folder_path, subtitles,
+ video_filename=os.path.basename(reversed_path))
+ subtitles = guess_external_subtitles(full_dest_folder_path, subtitles, "movie",
+ previously_indexed_subtitles_to_exclude)
+ except Exception as e:
+ logging.exception(f"BAZARR unable to index external subtitles for this file {reversed_path}: {repr(e)}") # noqa: G004
+ else:
+ for subtitle, language in subtitles.items():
+ valid_language = False
+ if language:
+ if hasattr(language, 'alpha3'):
+ valid_language = alpha2_from_alpha3(language.alpha3)
+ else:
+ logging.debug(f"Skipping subtitles because we are unable to define language: {subtitle}") # noqa: G004
+ continue
- if not valid_language:
- logging.debug(f'{language.alpha3} is an unsupported language code.') # noqa: G004
- continue
+ if not valid_language:
+ logging.debug(f'{language.alpha3} is an unsupported language code.') # noqa: G004
+ continue
- subtitle_path = get_external_subtitles_path(reversed_path, subtitle)
+ subtitle_path = get_external_subtitles_path(reversed_path, subtitle)
- try:
- subtitle_size = os.stat(subtitle_path).st_size
- except FileNotFoundError:
- logging.debug(f"BAZARR skipping missing subtitle file: {subtitle_path}") # noqa: G004
- continue
-
- custom = CustomLanguage.found_external(subtitle, subtitle_path)
-
- if custom is not None:
- actual_subtitles.append([custom, _prr(subtitle_path),
- subtitle_size])
-
- elif str(language.basename) != 'und':
- if language.forced:
- language_str = f'{language}:forced'
- elif language.hi:
- language_str = f'{language}:hi'
- else:
- language_str = str(language)
- language_str = subtitle_language_with_sync_modifier(language_str, subtitle)
- language_str = subtitle_language_with_combined_modifier(language_str, subtitle)
- logging.debug(f"BAZARR external subtitles detected: {language_str}") # noqa: G004
- actual_subtitles.append([language_str, _prr(subtitle_path),
- subtitle_size])
+ try:
+ subtitle_size = os.stat(subtitle_path).st_size
+ except FileNotFoundError:
+ logging.debug(f"BAZARR skipping missing subtitle file: {subtitle_path}") # noqa: G004
+ continue
- database.execute(
- scoped(update(TableMovies)
- .values(subtitles=str(actual_subtitles))
- .where(TableMovies.path == original_path),
- TableMovies.arr_instance_id, owner_instance_id))
+ custom = CustomLanguage.found_external(subtitle, subtitle_path)
+
+ if custom is not None:
+ actual_subtitles.append([custom, _prr(subtitle_path),
+ subtitle_size])
+
+ elif str(language.basename) != 'und':
+ if language.forced:
+ language_str = f'{language}:forced'
+ elif language.hi:
+ language_str = f'{language}:hi'
+ else:
+ language_str = str(language)
+ language_str = subtitle_language_with_sync_modifier(language_str, subtitle)
+ language_str = subtitle_language_with_combined_modifier(language_str, subtitle)
+ logging.debug(f"BAZARR external subtitles detected: {language_str}") # noqa: G004
+ actual_subtitles.append([language_str, _prr(subtitle_path),
+ subtitle_size])
+
+ database.execute(
+ scoped(update(TableMovies)
+ .values(subtitles=str(actual_subtitles))
+ .where(TableMovies.path == original_path),
+ TableMovies.arr_instance_id, owner_instance_id))
matching_movies = database.execute(
scoped(select(TableMovies.radarrId, TableMovies.arr_instance_id)
.where(TableMovies.path == original_path),
@@ -437,12 +442,13 @@ def movies_full_scan_subtitles(job_id=None, use_cache=None, wait_for_completion=
.all()
jobs_queue.update_job_progress(job_id=job_id, progress_max=len(movies), progress_message='Indexing')
+ ownership_index = SyncOutputOwnerIndex()
for i, movie in enumerate(movies, start=1):
jobs_queue.update_job_progress(job_id=job_id, progress_value=i, progress_message=movie.title)
store_subtitles_movie(movie.path,
path_mappings.path_replace_instance(movie.path,
movie.arr_instance_id, 'movie'),
- use_cache=use_cache, arr_instance_id=movie.arr_instance_id)
+ use_cache=use_cache, arr_instance_id=movie.arr_instance_id, ownership_index=ownership_index)
logging.info('BAZARR All existing movie subtitles indexed from disk.')
@@ -460,8 +466,9 @@ def movies_scan_subtitles(no, arr_instance_id=None):
TableMovies.arr_instance_id, arr_instance_id)) \
.all()
+ ownership_index = SyncOutputOwnerIndex()
for movie in movies:
store_subtitles_movie(movie.path,
path_mappings.path_replace_instance(movie.path,
movie.arr_instance_id, 'movie'),
- use_cache=False, arr_instance_id=movie.arr_instance_id)
+ use_cache=False, arr_instance_id=movie.arr_instance_id, ownership_index=ownership_index)
diff --git a/bazarr/subtitles/indexer/series.py b/bazarr/subtitles/indexer/series.py
index 6566213962..f98d2323d6 100644
--- a/bazarr/subtitles/indexer/series.py
+++ b/bazarr/subtitles/indexer/series.py
@@ -12,7 +12,8 @@
get_audio_profile_languages, database, update, select
from languages.get_languages import alpha2_from_alpha3, get_language_set
from app.config import settings
-from utilities.helper import get_subtitle_destination_folder
+from utilities.helper import get_subtitle_destination_folder, get_target_folder
+from subtitles.tools.subsync_engines import SyncOutputOwnerIndex, subtitle_write_locks
from utilities.path_mappings import path_mappings
from utilities.video_analyzer import embedded_subs_reader
from app.event_handler import event_stream
@@ -30,7 +31,7 @@
gc.enable()
-def store_subtitles(original_path, reversed_path, use_cache=True, arr_instance_id=None):
+def store_subtitles(original_path, reversed_path, use_cache=True, arr_instance_id=None, ownership_index=None):
logging.debug(f'BAZARR started subtitles indexing for this file: {reversed_path}') # noqa: G004
actual_subtitles = []
# The owning instance decides everything below: which per-instance
@@ -102,86 +103,90 @@ def _prr(p):
"BAZARR error when trying to analyze this %s file: %s" % (os.path.splitext(reversed_path)[1], # noqa: G002
reversed_path))
pass
- try:
- dest_folder = get_subtitle_destination_folder()
- core.CUSTOM_PATHS = [dest_folder] if dest_folder else []
-
- # get previously indexed subtitles that haven't changed:
- item = database.execute(
- scoped(select(TableEpisodes.subtitles)
- .where(TableEpisodes.path == original_path),
- TableEpisodes.arr_instance_id, owner_instance_id)) \
- .first()
- if not item:
- previously_indexed_subtitles_to_exclude = []
- else:
- previously_indexed_subtitles = ast.literal_eval(item.subtitles) if item.subtitles else []
- previously_indexed_subtitles_to_exclude = [x for x in previously_indexed_subtitles
- if len(x) == 3 and
- x[1] and
- os.path.isfile(_pr(x[1])) and
- os.stat(_pr(x[1])).st_size == x[2]]
-
- subtitles = search_external_subtitles(reversed_path, languages=get_language_set(),
- only_one=settings.general.single_language)
- full_dest_folder_path = os.path.dirname(reversed_path)
- if dest_folder:
- if settings.general.subfolder == "absolute":
- full_dest_folder_path = dest_folder
- elif settings.general.subfolder == "relative":
- full_dest_folder_path = os.path.join(os.path.dirname(reversed_path), dest_folder)
- subtitles = add_sync_engine_outputs(full_dest_folder_path, subtitles)
- subtitles = add_combined_outputs(full_dest_folder_path, subtitles,
- video_filename=os.path.basename(reversed_path))
- subtitles = guess_external_subtitles(full_dest_folder_path, subtitles, "series",
- previously_indexed_subtitles_to_exclude)
- except Exception as e:
- logging.exception(f"BAZARR unable to index external subtitles for this file {reversed_path}: {repr(e)}") # noqa: G004
- else:
- for subtitle, language in subtitles.items():
- valid_language = False
- if language:
- if hasattr(language, 'alpha3'):
- valid_language = alpha2_from_alpha3(language.alpha3)
+ destination = os.path.join(get_target_folder(reversed_path, create=False) or os.path.dirname(reversed_path),
+ '.destination')
+ with subtitle_write_locks(reversed_path, destination):
+ try:
+ dest_folder = get_subtitle_destination_folder()
+ core.CUSTOM_PATHS = [dest_folder] if dest_folder else []
+
+ # get previously indexed subtitles that haven't changed:
+ item = database.execute(
+ scoped(select(TableEpisodes.subtitles)
+ .where(TableEpisodes.path == original_path),
+ TableEpisodes.arr_instance_id, owner_instance_id)) \
+ .first()
+ if not item:
+ previously_indexed_subtitles_to_exclude = []
else:
- logging.debug(f"Skipping subtitles because we are unable to define language: {subtitle}") # noqa: G004
- continue
+ previously_indexed_subtitles = ast.literal_eval(item.subtitles) if item.subtitles else []
+ previously_indexed_subtitles_to_exclude = [x for x in previously_indexed_subtitles
+ if len(x) == 3 and
+ x[1] and
+ os.path.isfile(_pr(x[1])) and
+ os.stat(_pr(x[1])).st_size == x[2]]
+
+ subtitles = search_external_subtitles(reversed_path, languages=get_language_set(),
+ only_one=settings.general.single_language)
+ full_dest_folder_path = os.path.dirname(reversed_path)
+ if dest_folder:
+ if settings.general.subfolder == "absolute":
+ full_dest_folder_path = dest_folder
+ elif settings.general.subfolder == "relative":
+ full_dest_folder_path = os.path.join(os.path.dirname(reversed_path), dest_folder)
+ subtitles = add_sync_engine_outputs(full_dest_folder_path, subtitles,
+ video_path=reversed_path, ownership_index=ownership_index)
+ subtitles = add_combined_outputs(full_dest_folder_path, subtitles,
+ video_filename=os.path.basename(reversed_path))
+ subtitles = guess_external_subtitles(full_dest_folder_path, subtitles, "series",
+ previously_indexed_subtitles_to_exclude)
+ except Exception as e:
+ logging.exception(f"BAZARR unable to index external subtitles for this file {reversed_path}: {repr(e)}") # noqa: G004
+ else:
+ for subtitle, language in subtitles.items():
+ valid_language = False
+ if language:
+ if hasattr(language, 'alpha3'):
+ valid_language = alpha2_from_alpha3(language.alpha3)
+ else:
+ logging.debug(f"Skipping subtitles because we are unable to define language: {subtitle}") # noqa: G004
+ continue
- if not valid_language:
- logging.debug(f'{language.alpha3} is an unsupported language code.') # noqa: G004
- continue
+ if not valid_language:
+ logging.debug(f'{language.alpha3} is an unsupported language code.') # noqa: G004
+ continue
- subtitle_path = get_external_subtitles_path(reversed_path, subtitle)
+ subtitle_path = get_external_subtitles_path(reversed_path, subtitle)
- try:
- subtitle_size = os.stat(subtitle_path).st_size
- except FileNotFoundError:
- logging.debug(f"BAZARR skipping missing subtitle file: {subtitle_path}") # noqa: G004
- continue
-
- custom = CustomLanguage.found_external(subtitle, subtitle_path)
- if custom is not None:
- actual_subtitles.append([custom, _prr(subtitle_path),
- subtitle_size])
-
- elif str(language.basename) != 'und':
- if language.forced:
- language_str = f'{language}:forced'
- elif language.hi:
- language_str = f'{language}:hi'
- else:
- language_str = str(language)
- language_str = subtitle_language_with_sync_modifier(language_str, subtitle)
- language_str = subtitle_language_with_combined_modifier(language_str, subtitle)
- logging.debug(f"BAZARR external subtitles detected: {language_str}") # noqa: G004
- actual_subtitles.append([language_str, _prr(subtitle_path),
- subtitle_size])
+ try:
+ subtitle_size = os.stat(subtitle_path).st_size
+ except FileNotFoundError:
+ logging.debug(f"BAZARR skipping missing subtitle file: {subtitle_path}") # noqa: G004
+ continue
- database.execute(
- scoped(update(TableEpisodes)
- .values(subtitles=str(actual_subtitles))
- .where(TableEpisodes.path == original_path),
- TableEpisodes.arr_instance_id, owner_instance_id))
+ custom = CustomLanguage.found_external(subtitle, subtitle_path)
+ if custom is not None:
+ actual_subtitles.append([custom, _prr(subtitle_path),
+ subtitle_size])
+
+ elif str(language.basename) != 'und':
+ if language.forced:
+ language_str = f'{language}:forced'
+ elif language.hi:
+ language_str = f'{language}:hi'
+ else:
+ language_str = str(language)
+ language_str = subtitle_language_with_sync_modifier(language_str, subtitle)
+ language_str = subtitle_language_with_combined_modifier(language_str, subtitle)
+ logging.debug(f"BAZARR external subtitles detected: {language_str}") # noqa: G004
+ actual_subtitles.append([language_str, _prr(subtitle_path),
+ subtitle_size])
+
+ database.execute(
+ scoped(update(TableEpisodes)
+ .values(subtitles=str(actual_subtitles))
+ .where(TableEpisodes.path == original_path),
+ TableEpisodes.arr_instance_id, owner_instance_id))
matching_episodes = database.execute(
scoped(select(TableEpisodes.sonarrEpisodeId, TableEpisodes.sonarrSeriesId,
TableEpisodes.arr_instance_id)
@@ -458,6 +463,7 @@ def series_full_scan_subtitles(job_id=None, use_cache=None, wait_for_completion=
).all()
jobs_queue.update_job_progress(job_id=job_id, progress_max=len(episodes), progress_message='Indexing')
+ ownership_index = SyncOutputOwnerIndex()
for i, episode in enumerate(episodes, start=1):
jobs_queue.update_job_progress(
job_id=job_id, progress_value=i,
@@ -465,7 +471,7 @@ def series_full_scan_subtitles(job_id=None, use_cache=None, wait_for_completion=
store_subtitles(episode.path,
path_mappings.path_replace_instance(episode.path,
episode.arr_instance_id, 'series'),
- use_cache=use_cache, arr_instance_id=episode.arr_instance_id)
+ use_cache=use_cache, arr_instance_id=episode.arr_instance_id, ownership_index=ownership_index)
logging.info('BAZARR All existing episode subtitles indexed from disk.')
@@ -483,8 +489,9 @@ def series_scan_subtitles(no, arr_instance_id=None):
TableEpisodes.arr_instance_id, arr_instance_id))\
.all()
+ ownership_index = SyncOutputOwnerIndex()
for episode in episodes:
store_subtitles(episode.path,
path_mappings.path_replace_instance(episode.path,
episode.arr_instance_id, 'series'),
- use_cache=False, arr_instance_id=episode.arr_instance_id)
+ use_cache=False, arr_instance_id=episode.arr_instance_id, ownership_index=ownership_index)
diff --git a/bazarr/subtitles/indexer/utils.py b/bazarr/subtitles/indexer/utils.py
index 120ca33c22..07c256e1e8 100644
--- a/bazarr/subtitles/indexer/utils.py
+++ b/bazarr/subtitles/indexer/utils.py
@@ -2,6 +2,7 @@
import os
import logging
+import unicodedata
from guess_language import guess_language
from subliminal_patch import core
@@ -12,7 +13,7 @@
from app.config import settings
from utilities.path_mappings import path_mappings
from languages.custom_lang import CustomLanguage
-from subtitles.tools.subsync_engines import SYNC_ENGINES, sync_engine_from_output_path
+from subtitles.tools.subsync_engines import SYNC_ENGINES, sync_engine_from_output_path, sync_output_owner_is_unique
import re as _re_combine
@@ -46,6 +47,23 @@ def get_external_subtitles_path(file, subtitle):
return path
+def get_subtitle_destination_path(file, subtitle):
+ """Where a new external subtitle for ``file`` gets written.
+
+ get_external_subtitles_path() answers only for files that already exist, which is
+ right for indexing and wrong for a first-time write: in the absolute and relative
+ subfolder modes it returned None and a translation crashed on save. An existing file
+ in either location is still preferred so a re-run overwrites what the user already
+ sees; otherwise the configured folder is used and created, the way downloads do it.
+ """
+ existing = get_external_subtitles_path(file, subtitle)
+ if existing:
+ return existing
+ from utilities.helper import get_target_folder
+ folder = get_target_folder(file) or os.path.dirname(file)
+ return os.path.join(folder, subtitle)
+
+
def normalize_subtitle_language_variant(language, forced=False, hi=False):
language_text = str(language)
parts = language_text.split(':')
@@ -75,14 +93,18 @@ def sync_engine_from_subtitle_name(subtitle):
return sync_engine_from_output_path(subtitle)
-def _language_code_from_sync_engine_output(subtitle):
- filename = os.path.basename(subtitle).lower()
+def _normalized_video_stem(stem):
+ return unicodedata.normalize('NFC', stem.lower())
+
+
+def _language_code_from_sync_engine_output(subtitle, video_filename=None):
+ filename = os.path.basename(subtitle)
stem, extension = os.path.splitext(filename)
- if extension not in core.SUBTITLE_EXTENSIONS:
+ if extension.lower() not in core.SUBTITLE_EXTENSIONS:
return None
parts = stem.split('.')
- if len(parts) < 3 or parts[-1] not in SYNC_ENGINES:
+ if len(parts) < 3 or parts[-1].lower() not in SYNC_ENGINES:
return None
parts = parts[:-1]
@@ -93,40 +115,86 @@ def _language_code_from_sync_engine_output(subtitle):
# combined output is a known limitation: it is left on disk but not indexed
# as a tracked variant. Overwrite-mode sync of a combined file works in
# place (it keeps the Movie.en.combined-hu.srt name) and is unaffected.
- if parts and _COMBINED_MODIFIER_PATTERN.match(parts[-1]):
+ if parts and _COMBINED_MODIFIER_PATTERN.match(parts[-1].lower()):
return None
variants = []
- if parts and parts[-1] in ['hi', 'sdh', 'cc']:
+ if parts and parts[-1].lower() in ['hi', 'sdh', 'cc']:
variants.append('hi')
parts = parts[:-1]
- if parts and parts[-1] == 'forced':
+ if parts and parts[-1].lower() == 'forced':
variants.append('forced')
parts = parts[:-1]
if not parts:
return None
- language = parts[-1].replace('_', '-')
+ if video_filename is not None:
+ video_stem = os.path.splitext(video_filename)[0]
+ if _normalized_video_stem('.'.join(parts[:-1])) != _normalized_video_stem(video_stem):
+ return None
+
+ language = parts[-1].lower().replace('_', '-')
if not language:
return None
return ':'.join([language] + variants)
-def add_sync_engine_outputs(dest_folder, subtitles):
+def add_sync_engine_outputs(dest_folder, subtitles, video_filename=None, video_path=None, ownership_index=None):
+ """Add generated outputs belonging to the video, regardless of save preferences."""
if not os.path.isdir(dest_folder):
return subtitles
+ video_stem = None
+ media_stems = set()
+ if video_path is not None:
+ video_filename = os.path.basename(video_path)
+ if video_filename is not None:
+ video_stem = _normalized_video_stem(os.path.splitext(video_filename)[0])
+ media_folder = (os.path.dirname(video_path) or '.') if video_path is not None else dest_folder
+ with os.scandir(media_folder) as media_files:
+ media_stems = {_normalized_video_stem(os.path.splitext(entry.name)[0])
+ for entry in media_files
+ if entry.is_file() and entry.name.lower().endswith(core.VIDEO_EXTENSIONS)}
+
+ ownership = {}
for subtitle in os.listdir(dest_folder):
- if subtitle in subtitles or not sync_engine_from_subtitle_name(subtitle):
+ if not sync_engine_from_subtitle_name(subtitle):
continue
subtitle_path = os.path.join(dest_folder, subtitle)
if not os.path.isfile(subtitle_path):
continue
- language_code = _language_code_from_sync_engine_output(subtitle)
+ if video_path is not None:
+ stem, extension = os.path.splitext(subtitle)
+ source_path = os.path.join(dest_folder, stem.rsplit('.', 1)[0] + extension)
+ if source_path not in ownership:
+ ownership[source_path] = sync_output_owner_is_unique(video_path, source_path,
+ ownership_index=ownership_index)
+ if not ownership[source_path]:
+ subtitles.pop(subtitle, None)
+ continue
+ if subtitle in subtitles:
+ continue
+
+ if video_stem is not None:
+ stem, extension = os.path.splitext(subtitle)
+ if extension.lower() not in core.SUBTITLE_EXTENSIONS:
+ continue
+ owner_stem = _normalized_video_stem(stem.rsplit('.', 1)[0])
+ if owner_stem == video_stem:
+ # The complete basename owns an untagged output. Detect its
+ # language normally, even if the save preference has changed.
+ subtitles[subtitle] = None
+ continue
+ if owner_stem in media_stems:
+ # Movie.en.mkv owns Movie.en.ffsubsync.srt when it exists.
+ # Without that sibling, it can be a tagged output of Movie.mkv.
+ continue
+
+ language_code = _language_code_from_sync_engine_output(subtitle, video_filename=video_filename)
if not language_code:
- logging.debug("BAZARR skipping generated sync subtitle with unknown language: %s", subtitle_path)
+ logging.debug("BAZARR skipping unrelated or unrecognized generated sync subtitle: %s", subtitle_path)
continue
try:
diff --git a/bazarr/subtitles/manual.py b/bazarr/subtitles/manual.py
index 781c10125d..177df32209 100644
--- a/bazarr/subtitles/manual.py
+++ b/bazarr/subtitles/manual.py
@@ -4,10 +4,12 @@
import os
import sys
import logging
+from functools import partial
import subliminal
from subzero.language import Language
from subliminal_patch.core import save_subtitles
+from subtitles.tools.subsync_engines import subtitle_write_locks, write_subtitle_file
from subliminal_patch.core_persistent import list_all_subtitles, download_subtitles
from subliminal_patch.score import compute_score, DEFAULT_SCORES
@@ -196,13 +198,18 @@ def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provide
try:
chmod = int(settings.general.chmod, 8) if not sys.platform.startswith(
'win') and settings.general.chmod_enabled else None
- saved_subtitles = save_subtitles(video.original_path, [subtitle],
- single=settings.general.single_language,
- tags=None, # fixme
- directory=get_target_folder(path),
- chmod=chmod,
- formats=(subtitle.format,),
- path_decoder=force_unicode)
+ with subtitle_write_locks(path, os.path.join(get_target_folder(path) or os.path.dirname(path), '.destination')):
+ written_paths = []
+ saved_subtitles = save_subtitles(video.original_path, [subtitle],
+ single=settings.general.single_language,
+ tags=None, # fixme
+ directory=get_target_folder(path),
+ chmod=chmod,
+ formats=(subtitle.format,),
+ path_decoder=force_unicode,
+ write_subtitle=partial(write_subtitle_file, path, written_paths=written_paths))
+ saved_subtitles = [saved for saved in saved_subtitles if saved.storage_path in written_paths]
+
except Exception as e:
logging.exception(f'BAZARR Error saving Subtitles file to disk for this file {path}: {repr(e)}') # noqa: G004
return 'Error saving Subtitles file to disk'
diff --git a/bazarr/subtitles/post_processing.py b/bazarr/subtitles/post_processing.py
index 2391340bcb..4054d737b8 100644
--- a/bazarr/subtitles/post_processing.py
+++ b/bazarr/subtitles/post_processing.py
@@ -6,9 +6,23 @@
import subprocess
from locale import getpreferredencoding
+from utilities.helper import get_target_folder
+from subtitles.tools.subsync_engines import subtitle_write_locks, subtitle_mutation
-def postprocessing(command, path):
+def postprocessing(command, path, subtitle_path=None):
+ # Configured commands can mutate subtitles in place. This is the one boundary
+ # that must hold this media's mutation locks while the external command runs.
+ destination = os.path.join(get_target_folder(path, create=False) or os.path.dirname(path), '.destination')
+ with subtitle_write_locks(path, path, destination, subtitle_path or path) as states:
+ watched_paths = {watched for state in states.values() for watched in state.revisions}
+ if subtitle_path:
+ watched_paths.add(subtitle_path)
+ with subtitle_mutation(path, *watched_paths):
+ return _postprocessing_locked(command, path)
+
+
+def _postprocessing_locked(command, path):
try:
encoding = getpreferredencoding()
if os.name == 'nt':
diff --git a/bazarr/subtitles/processing.py b/bazarr/subtitles/processing.py
index a4384fa8ab..71845cba6f 100644
--- a/bazarr/subtitles/processing.py
+++ b/bazarr/subtitles/processing.py
@@ -71,15 +71,6 @@ def _trigger_auto_translation(downloaded_lang, subtitle_path, video_path, media_
if forced:
return
- min_score = settings.translator.min_source_score
- if source_score_percent is not None and source_score_percent < min_score:
- logging.info(
- 'BAZARR auto-translate skipped: source score %.1f%% '
- 'below threshold %.1f%% for %s',
- source_score_percent, min_score, video_path,
- )
- return
-
# Fetch the episode/movie row that postprocess_subtitles dereferences
# for plex/jellyfin refresh (sonarrSeriesId, imdbId, season, episode,
# tvdbId for episodes; imdbId, tmdbId for movies). Passing None here
@@ -140,6 +131,23 @@ def _trigger_auto_translation(downloaded_lang, subtitle_path, video_path, media_
if not profile:
return
+ translation_targets = [
+ item for item in profile.get('items', [])
+ if item.get('translate_from') == downloaded_lang
+ and item.get('language') != downloaded_lang
+ ]
+ if not translation_targets:
+ return
+
+ min_score = settings.translator.min_source_score
+ if source_score_percent is not None and source_score_percent < min_score:
+ logging.info(
+ 'BAZARR auto-translate skipped: source score %.1f%% '
+ 'below threshold %.1f%% for %s',
+ source_score_percent, min_score, video_path,
+ )
+ return
+
# Hoisted out of the loop: check_missing_languages is independent of the
# profile item being considered, so calling it once per profile item
# (potentially N database/indexer queries) is wasteful. Compute the
@@ -160,14 +168,8 @@ def _trigger_auto_translation(downloaded_lang, subtitle_path, video_path, media_
else:
missing_codes.add(code2)
- for item in profile.get('items', []):
+ for item in translation_targets:
target_lang = item.get('language')
- translate_from = item.get('translate_from')
-
- if not translate_from or translate_from != downloaded_lang:
- continue
- if target_lang == downloaded_lang:
- continue
target_code = profile_item_language_code(item)
if target_code not in missing_codes:
@@ -331,7 +333,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
if not use_pp_threshold or (use_pp_threshold and percent_score < pp_threshold):
logging.debug(f"BAZARR Using post-processing command: {command}") # noqa: G004
- postprocessing(command, path)
+ postprocessing(command, path, subtitle_path=downloaded_path)
set_chmod(subtitles_path=downloaded_path)
else:
logging.debug(f"BAZARR post-processing skipped because subtitles score isn't below this " # noqa: G004
diff --git a/bazarr/subtitles/sync.py b/bazarr/subtitles/sync.py
index 26c923be64..f84cedf045 100644
--- a/bazarr/subtitles/sync.py
+++ b/bazarr/subtitles/sync.py
@@ -5,7 +5,7 @@
import gc
from app.config import settings
-from app.jobs_queue import jobs_queue
+from app.jobs_queue import jobs_queue, JobCancelled
from subtitles.tools.subsyncer import SubSyncer
from subtitles.tools.subsync_engines import (
DEFAULT_ENABLED_ENGINES,
@@ -18,8 +18,13 @@
REASON_MISSING_ENGINE,
REASON_OUTPUT_EXISTS,
REASON_RESULT_REJECTED,
+ REASON_SOURCE_CHANGED,
+ REASON_DESTINATION_CHANGED,
+ REASON_DESTINATION_AMBIGUOUS,
is_sync_engine_output,
normalize_enabled_engines,
+ release_subtitle_publication,
+ release_unqueued_subtitle_publication,
)
@@ -38,6 +43,9 @@
"when it cannot confidently align a file. {message}"),
REASON_RESULT_REJECTED: "{label} result rejected: {message}",
REASON_ENGINE_FAILED: "{label} failed: {message}",
+ REASON_SOURCE_CHANGED: "The uploaded subtitle was replaced or deleted. Sync was skipped.",
+ REASON_DESTINATION_CHANGED: "The synchronized subtitle destination was changed or deleted. Sync was skipped.",
+ REASON_DESTINATION_AMBIGUOUS: "The synchronized subtitle destination has no unique media owner. Sync was skipped.",
}
_ENGINE_MESSAGE_LIMIT = 160
@@ -251,103 +259,135 @@ def sync_subtitles(video_path,
callback=None,
track_job_progress=True,
arr_instance_id=None,
- owns_job_progress=True):
- # The audio-sync settings resolve against the owning instance (#227); a None
- # owner / unset override yields the global value, so legacy paths are
- # unchanged. The use_subsync gate is evaluated inline via the module-level
- # helper so NO non-signature local is created before add_job_from_function
- # below, which re-passes the frame's locals as kwargs on re-invocation.
- if (not _resolve_subsync_overrides(
- arr_instance_id, bool(sonarr_episode_id), enabled_engines, max_offset_seconds)[0]
- and not force_sync):
- logging.debug('BAZARR automatic syncing is disabled in settings. Skipping sync routine.')
- return False
+ owns_job_progress=True,
+ source_version=None,
+ on_success=None):
+ try:
+ # The audio-sync settings resolve against the owning instance (#227); a None
+ # owner / unset override yields the global value, so legacy paths are
+ # unchanged. The use_subsync gate is evaluated inline via the module-level
+ # helper so NO non-signature local is created before add_job_from_function
+ # below, which re-passes the frame's locals as kwargs on re-invocation.
+ if (not _resolve_subsync_overrides(
+ arr_instance_id, bool(sonarr_episode_id), enabled_engines, max_offset_seconds)[0]
+ and not force_sync):
+ logging.debug('BAZARR automatic syncing is disabled in settings. Skipping sync routine.')
+ release_subtitle_publication(source_version)
+ return False
- if is_sync_engine_output(srt_path):
- logging.debug('BAZARR generated sync output cannot be synchronized again. Skipping: %s', srt_path)
- _report_progress(job_id, track_job_progress, owns_job_progress, 'Sync skipped',
- value='max', name=f"Skipped sync for {srt_path}")
- return False
+ if is_sync_engine_output(srt_path):
+ logging.debug('BAZARR generated sync output cannot be synchronized again. Skipping: %s', srt_path)
+ _report_progress(job_id, track_job_progress, owns_job_progress, 'Sync skipped',
+ value='max', name=f"Skipped sync for {srt_path}")
+ release_subtitle_publication(source_version)
+ return False
- if not job_id and track_job_progress:
- jobs_queue.add_job_from_function(
- f"Syncing {srt_path}",
- is_progress=True,
- progress_max=_sync_progress_total(enabled_engines),
- )
- return False
+ if not job_id and track_job_progress:
+ if not jobs_queue.add_job_from_function(
+ f"Syncing {srt_path}",
+ is_progress=True,
+ progress_max=_sync_progress_total(enabled_engines),
+ ):
+ release_unqueued_subtitle_publication(source_version, jobs_queue)
+ return False
- # Past the enqueue point it is safe to bind locals. Resolve the per-instance
- # overrides for the real run (the use_subsync gate already passed above).
- (_, use_subsync_threshold, subsync_threshold,
- enabled_engines, max_offset_seconds) = _resolve_subsync_overrides(
- arr_instance_id, bool(sonarr_episode_id), enabled_engines, max_offset_seconds)
- progress_total = _sync_progress_total(enabled_engines)
+ except BaseException:
+ if job_id:
+ release_subtitle_publication(source_version)
+ else:
+ release_unqueued_subtitle_publication(source_version, jobs_queue)
+ raise
- def report(message, value=None, total=None, name=None):
- _report_progress(job_id, track_job_progress, owns_job_progress, message, value, total, name)
+ try:
+ # Past the enqueue point it is safe to bind locals. Resolve the per-instance
+ # overrides for the real run (the use_subsync gate already passed above).
+ (_, use_subsync_threshold, subsync_threshold,
+ enabled_engines, max_offset_seconds) = _resolve_subsync_overrides(
+ arr_instance_id, bool(sonarr_episode_id), enabled_engines, max_offset_seconds)
+ progress_total = _sync_progress_total(enabled_engines)
- report('Preparing synchronization', value=0, total=progress_total, name=f"Syncing {srt_path}")
+ def report(message, value=None, total=None, name=None):
+ _report_progress(job_id, track_job_progress, owns_job_progress, message, value, total, name)
- def update_progress(message, value, total):
- report(message, value=value, total=total)
+ report('Preparing synchronization', value=0, total=progress_total, name=f"Syncing {srt_path}")
- if forced:
- logging.debug('BAZARR cannot sync forced subtitles. Skipping sync routine.')
- report('Sync skipped', value='max', name=f"Skipped sync for {srt_path}")
- return False
+ def update_progress(message, value, total):
+ report(message, value=value, total=total)
- logging.debug(f'BAZARR automatic syncing is enabled in settings. We\'ll try to sync this ' # noqa: G004
- f'subtitles: {srt_path}.')
- if not use_subsync_threshold or (use_subsync_threshold and percent_score <= float(subsync_threshold)):
- subsync = SubSyncer()
- sync_kwargs = {
- 'video_path': video_path,
- 'srt_path': srt_path,
- 'srt_lang': srt_lang,
- 'forced': forced,
- 'hi': hi,
- 'max_offset_seconds': max_offset_seconds,
- 'no_fix_framerate': no_fix_framerate,
- 'gss': gss,
- 'reference': reference,
- 'sonarr_series_id': sonarr_series_id,
- 'sonarr_episode_id': sonarr_episode_id,
- 'radarr_id': radarr_id,
- 'job_id': job_id,
- 'force_sync': force_sync,
- 'output_mode': output_mode,
- 'enabled_engines': enabled_engines,
- 'progress_callback': update_progress if track_job_progress else None,
- 'arr_instance_id': arr_instance_id,
- }
- sync_result = None
- try:
- sync_result = subsync.sync(**sync_kwargs)
- if sync_result and sync_result.success:
- if callback:
- callback()
- elif getattr(sync_result, 'output_mode', None) == OUTPUT_MODE_KEEP_ALL:
- _index_keep_all_outputs(
- video_path,
- sonarr_series_id=sonarr_series_id,
- sonarr_episode_id=sonarr_episode_id,
- radarr_id=radarr_id,
- arr_instance_id=arr_instance_id,
- )
- except Exception:
- logging.exception(f'BAZARR an unhandled exception occurs during the synchronization process for this ' # noqa: G004
- f'subtitle file: {srt_path}')
+ if forced:
+ logging.debug('BAZARR cannot sync forced subtitles. Skipping sync routine.')
+ report('Sync skipped', value='max', name=f"Skipped sync for {srt_path}")
+ release_subtitle_publication(source_version)
return False
- else:
- return bool(sync_result and sync_result.success)
- finally:
- report(_sync_outcome_message(sync_result), value='max',
- name=_sync_complete_job_name(srt_path, sync_result))
- del subsync
- gc.collect()
-
- logging.debug(f"BAZARR subsync skipped because subtitles score isn't below this " # noqa: G004
- f"threshold value: {subsync_threshold}%")
- report('Sync skipped', value='max', name=f"Skipped sync for {srt_path}")
- return False
+
+ logging.debug(f'BAZARR automatic syncing is enabled in settings. We\'ll try to sync this ' # noqa: G004
+ f'subtitles: {srt_path}.')
+ if not use_subsync_threshold or (use_subsync_threshold and percent_score <= float(subsync_threshold)):
+ subsync = SubSyncer()
+ sync_kwargs = {
+ 'video_path': video_path,
+ 'srt_path': srt_path,
+ 'srt_lang': srt_lang,
+ 'forced': forced,
+ 'hi': hi,
+ 'max_offset_seconds': max_offset_seconds,
+ 'no_fix_framerate': no_fix_framerate,
+ 'gss': gss,
+ 'reference': reference,
+ 'sonarr_series_id': sonarr_series_id,
+ 'sonarr_episode_id': sonarr_episode_id,
+ 'radarr_id': radarr_id,
+ 'job_id': job_id,
+ 'force_sync': force_sync,
+ 'output_mode': output_mode,
+ 'enabled_engines': enabled_engines,
+ 'progress_callback': update_progress if track_job_progress else None,
+ 'arr_instance_id': arr_instance_id,
+ }
+ sync_result = None
+ if source_version is not None:
+ sync_kwargs['source_version'] = source_version
+ try:
+ sync_result = subsync.sync(**sync_kwargs)
+ if sync_result and sync_result.success:
+ if callback and source_version is None:
+ callback()
+ elif not callback and getattr(sync_result, 'output_mode', None) == OUTPUT_MODE_KEEP_ALL:
+ _index_keep_all_outputs(
+ video_path,
+ sonarr_series_id=sonarr_series_id,
+ sonarr_episode_id=sonarr_episode_id,
+ radarr_id=radarr_id,
+ arr_instance_id=arr_instance_id,
+ )
+ except JobCancelled:
+ raise
+ except Exception:
+ logging.exception(f'BAZARR an unhandled exception occurs during the synchronization process for this ' # noqa: G004
+ f'subtitle file: {srt_path}')
+ return False
+ else:
+ return bool(sync_result and sync_result.success)
+ finally:
+ try:
+ try:
+ if callback and source_version is not None:
+ callback()
+ finally:
+ if on_success and (sync_result or getattr(subsync, 'sync_result', None)) and (
+ sync_result or subsync.sync_result).success:
+ on_success()
+ report(_sync_outcome_message(sync_result), value='max',
+ name=_sync_complete_job_name(srt_path, sync_result))
+ finally:
+ release_subtitle_publication(source_version)
+ del subsync
+ gc.collect()
+
+ logging.debug(f"BAZARR subsync skipped because subtitles score isn't below this " # noqa: G004
+ f"threshold value: {subsync_threshold}%")
+ report('Sync skipped', value='max', name=f"Skipped sync for {srt_path}")
+ release_subtitle_publication(source_version)
+ return False
+ finally:
+ release_subtitle_publication(source_version)
diff --git a/bazarr/subtitles/tools/combine/main.py b/bazarr/subtitles/tools/combine/main.py
index 7c6a7cfb55..1a7b4163ad 100644
--- a/bazarr/subtitles/tools/combine/main.py
+++ b/bazarr/subtitles/tools/combine/main.py
@@ -3,7 +3,9 @@
import logging
import os
import re
+import stat
from dataclasses import dataclass
+from subtitles.tools.subsync_engines import staged_subtitle_write, subtitle_mutation, sync_output_owner_is_unique
from .composer import compose
from .naming import compose_combined_filename, external_subtitles_dir
@@ -84,6 +86,8 @@ def try_combine_for_video(video_path, media_type, sonarr_series_id=None,
# inside this video's external-subtitles directory. Re-assert it here so a
# crafted code can never steer makedirs/open/remove outside that folder.
safe_root = os.path.realpath(external_subtitles_dir(video_path))
+ if os.path.islink(out_path) or not sync_output_owner_is_unique(video_path, out_path):
+ return CombineResult(status="skipped", reason="combined output has no unique regular-file owner")
out_path = os.path.realpath(out_path)
if os.path.commonpath([safe_root, out_path]) != safe_root:
return CombineResult(
@@ -92,36 +96,20 @@ def try_combine_for_video(video_path, media_type, sonarr_series_id=None,
)
try:
- content = compose(
- primary_path=sources.primary,
- secondary_paths=sources.secondaries,
- format=rule["format"],
- )
- except Exception as e:
- logging.exception("BAZARR combine compose failed for %s", video_path)
- return CombineResult(status="failed", error=str(e))
-
- try:
- # Create the destination directory (mirrors how Bazarr's
- # get_target_folder makedirs the configured subtitle subfolder) so a
- # first-time combine into a not-yet-created absolute/relative folder
- # succeeds instead of failing on open().
out_dir = os.path.dirname(out_path)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
- with open(out_path, "wb") as fh:
- fh.write(content)
- except OSError as e:
- logging.exception("BAZARR combine write failed for %s", out_path)
+ with staged_subtitle_write(
+ video_path, out_path, source_paths=(sources.primary, *sources.secondaries),
+ after_publish=lambda: _remove_stale_combined_siblings(out_path, video_path)) as temporary:
+ content = compose(primary_path=sources.primary, secondary_paths=sources.secondaries,
+ format=rule["format"])
+ with open(temporary, "wb") as fh:
+ fh.write(content)
+ except Exception as e:
+ logging.exception("BAZARR combine could not publish %s", out_path)
return CombineResult(status="failed", error=str(e))
- # A combined output is one logical subtitle. Drop any sibling combined
- # file in a different subtitle format (left over when the profile format
- # changes or a rebuild switches format) before re-indexing, so the same
- # combined language is not indexed twice and the editor does not load a
- # stale positioned ASS as overlapping cues.
- _remove_stale_combined_siblings(out_path, video_path)
-
_post_write(out_path, video_path, media_type,
sonarr_episode_id, radarr_id)
@@ -173,19 +161,22 @@ def _remove_stale_combined_siblings(out_path, video_path):
crafted output path can never steer os.remove outside that folder."""
safe_dir = os.path.realpath(external_subtitles_dir(video_path))
out_real = os.path.realpath(out_path)
- if os.path.dirname(out_real) != safe_dir:
+ if os.path.dirname(out_real) != safe_dir or not sync_output_owner_is_unique(video_path, out_path):
return
root, _ext = os.path.splitext(out_real)
for ext in _COMBINED_OUTPUT_EXTS:
- sibling = os.path.realpath(root + ext)
+ sibling = root + ext
if sibling == out_real:
continue
try:
if os.path.commonpath([safe_dir, sibling]) != safe_dir:
continue
- if os.path.isfile(sibling):
- os.remove(sibling)
+ if stat.S_ISREG(os.lstat(sibling).st_mode):
+ with subtitle_mutation(video_path, sibling):
+ os.remove(sibling)
logging.info("BAZARR combine removed stale sibling %s", sibling)
+ except FileNotFoundError:
+ continue
except OSError:
logging.exception(
"BAZARR combine could not remove stale sibling %s", sibling)
diff --git a/bazarr/subtitles/tools/delete.py b/bazarr/subtitles/tools/delete.py
index b4fb77099e..b572960fdf 100644
--- a/bazarr/subtitles/tools/delete.py
+++ b/bazarr/subtitles/tools/delete.py
@@ -15,6 +15,8 @@
from subtitles.indexer.series import store_subtitles
from subtitles.indexer.movies import store_subtitles_movie
from subtitles.processing import ProcessSubtitlesResult
+from subtitles.tools.subsync_engines import (subtitle_write_lock, subtitle_write_locks,
+ quarantine_sync_outputs_after_mutation)
from sonarr.history import history_log
from radarr.history import history_log_movie
from sonarr.notify import notify_sonarr
@@ -23,6 +25,21 @@
from jellyfin.operations import jellyfin_refresh_item
+def _delete_subtitle_file(media_path, subtitle_path):
+ with subtitle_write_locks(media_path, subtitle_path):
+ state = subtitle_write_lock(media_path, os.path.dirname(subtitle_path))
+ try:
+ os.remove(subtitle_path)
+ except OSError as exc:
+ if isinstance(exc, FileNotFoundError):
+ state.changed(subtitle_path)
+ logging.exception('BAZARR cannot delete subtitles file: %s', subtitle_path)
+ return False
+ state.changed(subtitle_path)
+ quarantine_sync_outputs_after_mutation(media_path, subtitle_path)
+ return True
+
+
def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_path, sonarr_series_id=None,
sonarr_episode_id=None, radarr_id=None, arr_instance_id=None):
if not subtitles_path:
@@ -81,64 +98,56 @@ def prr(p):
hearing_impaired=None)
if media_type == 'series':
- try:
- os.remove(pr(subtitles_path))
- except OSError:
- logging.exception(f'BAZARR cannot delete subtitles file: {subtitles_path}') # noqa: G004
- store_subtitles(prr(media_path), media_path, arr_instance_id=arr_instance_id)
+ removed = _delete_subtitle_file(media_path, pr(subtitles_path))
+ store_subtitles(prr(media_path), media_path, arr_instance_id=arr_instance_id)
+ if not removed:
return False
- else:
- history_log(0, sonarr_series_id, sonarr_episode_id, result, arr_instance_id=arr_instance_id)
- store_subtitles(prr(media_path), media_path, arr_instance_id=arr_instance_id)
- # Route the rescan at the OWNING instance's Sonarr (#156); None
- # owner = default server (legacy single-instance), unchanged.
- notify_sonarr(sonarr_series_id,
- arr_client=client_for_instance(database, arr_instance_id, enabled_only=False))
- event_stream(type='series', action='update', payload=sonarr_series_id)
- event_stream(type='episode-wanted', action='update', payload=sonarr_episode_id)
-
- if settings.general.use_plex and settings.plex.update_series_library:
- plex_refresh_item(metadata.imdbId, is_movie=False, season=metadata.season,
- episode=metadata.episode)
- if settings.general.use_jellyfin and settings.jellyfin.update_series_library:
- jellyfin_refresh_item(metadata.imdbId, is_movie=False, season=metadata.season,
- episode=metadata.episode, tvdb_id=metadata.tvdbId)
-
- # Call external webhook after all processing is complete
- call_external_webhook(
- subtitle_path=subtitles_path,
- media_path=media_path,
- language=language_log,
- media_type=media_type
- )
-
- return True
+ history_log(0, sonarr_series_id, sonarr_episode_id, result, arr_instance_id=arr_instance_id)
+ # Route the rescan at the OWNING instance's Sonarr (#156); None
+ # owner = default server (legacy single-instance), unchanged.
+ notify_sonarr(sonarr_series_id,
+ arr_client=client_for_instance(database, arr_instance_id, enabled_only=False))
+ event_stream(type='series', action='update', payload=sonarr_series_id)
+ event_stream(type='episode-wanted', action='update', payload=sonarr_episode_id)
+
+ if settings.general.use_plex and settings.plex.update_series_library:
+ plex_refresh_item(metadata.imdbId, is_movie=False, season=metadata.season,
+ episode=metadata.episode)
+ if settings.general.use_jellyfin and settings.jellyfin.update_series_library:
+ jellyfin_refresh_item(metadata.imdbId, is_movie=False, season=metadata.season,
+ episode=metadata.episode, tvdb_id=metadata.tvdbId)
+
+ # Call external webhook after all processing is complete
+ call_external_webhook(
+ subtitle_path=subtitles_path,
+ media_path=media_path,
+ language=language_log,
+ media_type=media_type
+ )
+
+ return True
else:
- try:
- os.remove(pr(subtitles_path))
- except OSError:
- logging.exception(f'BAZARR cannot delete subtitles file: {subtitles_path}') # noqa: G004
- store_subtitles_movie(prr(media_path), media_path, arr_instance_id=arr_instance_id)
+ removed = _delete_subtitle_file(media_path, pr(subtitles_path))
+ store_subtitles_movie(prr(media_path), media_path, arr_instance_id=arr_instance_id)
+ if not removed:
return False
- else:
- history_log_movie(0, radarr_id, result, arr_instance_id=arr_instance_id)
- store_subtitles_movie(prr(media_path), media_path, arr_instance_id=arr_instance_id)
- notify_radarr(radarr_id,
- arr_client=client_for_instance(database, arr_instance_id, enabled_only=False))
- event_stream(type='movie-wanted', action='update', payload=radarr_id)
-
- if settings.general.use_plex and settings.plex.update_movie_library:
- plex_refresh_item(metadata.imdbId, is_movie=True)
- if settings.general.use_jellyfin and settings.jellyfin.update_movie_library:
- jellyfin_refresh_item(metadata.imdbId, is_movie=True,
- tmdb_id=metadata.tmdbId)
-
- # Call external webhook after all processing is complete
- call_external_webhook(
- subtitle_path=subtitles_path,
- media_path=media_path,
- language=language_log,
- media_type=media_type
- )
-
- return True
+ history_log_movie(0, radarr_id, result, arr_instance_id=arr_instance_id)
+ notify_radarr(radarr_id,
+ arr_client=client_for_instance(database, arr_instance_id, enabled_only=False))
+ event_stream(type='movie-wanted', action='update', payload=radarr_id)
+
+ if settings.general.use_plex and settings.plex.update_movie_library:
+ plex_refresh_item(metadata.imdbId, is_movie=True)
+ if settings.general.use_jellyfin and settings.jellyfin.update_movie_library:
+ jellyfin_refresh_item(metadata.imdbId, is_movie=True,
+ tmdb_id=metadata.tmdbId)
+
+ # Call external webhook after all processing is complete
+ call_external_webhook(
+ subtitle_path=subtitles_path,
+ media_path=media_path,
+ language=language_log,
+ media_type=media_type
+ )
+
+ return True
diff --git a/bazarr/subtitles/tools/mods.py b/bazarr/subtitles/tools/mods.py
index 1f50053e16..ab505ff940 100644
--- a/bazarr/subtitles/tools/mods.py
+++ b/bazarr/subtitles/tools/mods.py
@@ -12,7 +12,9 @@
from app.jobs_queue import jobs_queue
from languages.custom_lang import CustomLanguage
from languages.get_languages import alpha3_from_alpha2
-from subtitles.indexer.utils import get_external_subtitles_path
+from subtitles.indexer.utils import get_subtitle_destination_path
+from utilities.helper import get_target_folder
+from subtitles.tools.subsync_engines import subtitle_write_locks, subtitle_mutation
def has_remove_hi(mods):
@@ -145,6 +147,12 @@ def apply_subtitle_mods(language, subtitle_path, mods, video_path,
def subtitles_apply_mods(language, subtitle_path, mods, video_path, arr_instance_id=None):
+ destination = os.path.join(get_target_folder(video_path, create=False) or os.path.dirname(video_path), '.destination')
+ with subtitle_write_locks(video_path, subtitle_path, destination):
+ return _apply_mods_locked(language, subtitle_path, mods, video_path, arr_instance_id)
+
+
+def _apply_mods_locked(language, subtitle_path, mods, video_path, arr_instance_id):
# The mod list is user-chosen here, so only the keep-lyrics preference is
# instance-relevant: resolve it against the media's owning instance (#227).
# A None owner keeps the legacy global-only behaviour (single-instance).
@@ -180,18 +188,19 @@ def subtitles_apply_mods(language, subtitle_path, mods, video_path, arr_instance
# get the real modded subtitles path taking into account if the user set up Bazarr to store external
# subtitles in a custom folder or relative folder
- modded_subtitles_path = get_external_subtitles_path(
+ modded_subtitles_path = get_subtitle_destination_path(
file=video_path,
subtitle=os.path.basename(modded_subtitles_path_if_alongside_video)
)
else:
modded_subtitles_path = subtitle_path
- if os.path.exists(subtitle_path):
- os.remove(subtitle_path)
+ with subtitle_mutation(video_path, subtitle_path, modded_subtitles_path):
+ if os.path.exists(subtitle_path):
+ os.remove(subtitle_path)
- if os.path.exists(modded_subtitles_path):
- os.remove(modded_subtitles_path)
+ if os.path.exists(modded_subtitles_path):
+ os.remove(modded_subtitles_path)
- with open(modded_subtitles_path, 'wb') as f:
- f.write(content)
+ with open(modded_subtitles_path, 'wb') as f:
+ f.write(content)
diff --git a/bazarr/subtitles/tools/subsync_engines.py b/bazarr/subtitles/tools/subsync_engines.py
index 3f69eb01f1..3f05fb6c12 100644
--- a/bazarr/subtitles/tools/subsync_engines.py
+++ b/bazarr/subtitles/tools/subsync_engines.py
@@ -2,9 +2,337 @@
import logging
import os
+import stat
+import shutil
import tempfile
+import unicodedata
+import uuid
from dataclasses import dataclass, field
+from contextlib import contextmanager, ExitStack, nullcontext
from pathlib import Path
+from threading import Lock, RLock
+from weakref import WeakValueDictionary
+
+
+_subtitle_write_locks = WeakValueDictionary()
+_subtitle_write_locks_guard = Lock()
+
+
+class _SubtitleWriteState:
+ def __init__(self):
+ self.lock = RLock()
+ self.revisions = {}
+
+ def __enter__(self):
+ self.lock.acquire()
+ return self
+
+ def __exit__(self, *exc):
+ self.lock.release()
+
+ def changed(self, path):
+ key = os.path.normcase(os.path.realpath(path))
+ self.revisions[key] = self.revisions.get(key, 0) + 1
+
+ def revision(self, path):
+ return self.revisions.setdefault(os.path.normcase(os.path.realpath(path)), 0)
+
+
+def subtitle_write_lock(video_path, subtitle_directory):
+ """Coordinate one media destination, including its subtitle language variants.
+
+ The saver can change HI tags and format extensions. The target directory and
+ video stem identify their shared destination without predicting those names.
+ Weak references release idle locks once all participating operations finish.
+ """
+ key = (os.path.normcase(os.path.realpath(subtitle_directory)),
+ os.path.normcase(os.path.splitext(os.path.basename(video_path))[0]))
+ with _subtitle_write_locks_guard:
+ lock = _subtitle_write_locks.get(key)
+ if lock is None:
+ lock = _SubtitleWriteState()
+ _subtitle_write_locks[key] = lock
+ return lock
+
+
+def subtitle_source_version(path):
+ """Identify the saved file a queued upload sync is allowed to replace."""
+ try:
+ stat = os.stat(path)
+ except OSError:
+ return None
+ return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
+
+
+class SubtitlePublication:
+ """Hold a media coordinator while a queued sync watches exact destinations."""
+
+ def __init__(self, video_path, source_path, source_version):
+ self.state = subtitle_write_lock(video_path, os.path.dirname(source_path))
+ self.source_path = source_path
+ self.source_version = source_version
+ with self.state:
+ self.source_revision = self.state.revision(source_path)
+ self.owns_destinations = sync_output_owner_is_unique(video_path, source_path)
+ self.destinations = {
+ str(path): (self.state.revision(path), subtitle_source_version(path))
+ for path in (engine_output_path(source_path, engine) for engine in SYNC_ENGINES)
+ }
+
+ def source_unchanged(self):
+ return (self.state is not None and self.source_version is not None
+ and self.state.revision(self.source_path) == self.source_revision
+ and subtitle_source_version(self.source_path) == self.source_version)
+
+ def destination_owned(self, path):
+ return self.owns_destinations and not os.path.islink(path)
+
+ def destination_unchanged(self, path):
+ expected = self.destinations.get(str(path))
+ return (self.state is not None and self.destination_owned(path) and expected is not None
+ and expected == (self.state.revision(path), subtitle_source_version(path)))
+
+ def release(self):
+ self.state = None
+
+
+def source_is_unchanged(path, version):
+ if isinstance(version, SubtitlePublication):
+ return version.source_unchanged()
+ return subtitle_source_version(path) == version
+
+
+def release_subtitle_publication(version):
+ if isinstance(version, SubtitlePublication):
+ version.release()
+
+
+def release_unqueued_subtitle_publication(version, queue):
+ if not isinstance(version, SubtitlePublication):
+ return
+ # Duplicate submission can pass the very same snapshot as the queued job.
+ # Its coordinator belongs to that job until the running call finishes.
+ with queue._queue_lock:
+ if any(job.kwargs.get('source_version') is version
+ for job in (*queue.jobs_pending_queue, *queue.jobs_running_queue)):
+ return
+ release_subtitle_publication(version)
+
+
+@contextmanager
+def subtitle_write_locks(video_path, *paths):
+ """Acquire participating subtitle directories in stable order."""
+ directories = sorted({os.path.normcase(os.path.realpath(os.path.dirname(path))) for path in (video_path, *paths)})
+ with ExitStack() as stack:
+ states = {directory: stack.enter_context(subtitle_write_lock(video_path, directory))
+ for directory in directories}
+ yield states
+
+
+@contextmanager
+def subtitle_mutation(video_path, *paths, invalidate_outputs=True):
+ """Coordinate exact local writes and invalidate pending publications.
+
+ Network and engine work stays outside. Indexers share this coordinator for
+ their external-file scan and database update.
+ """
+ with subtitle_write_locks(video_path, *paths) as states:
+ versions = {path: subtitle_source_version(path) for path in paths}
+ try:
+ yield
+ finally:
+ for path, version in versions.items():
+ if subtitle_source_version(path) != version:
+ directory = os.path.normcase(os.path.realpath(os.path.dirname(path)))
+ states[directory].changed(path)
+ if invalidate_outputs:
+ quarantine_sync_outputs_after_mutation(video_path, path)
+
+
+@contextmanager
+def staged_subtitle_write(video_path, destination, before_publish=None, allow_empty=False,
+ source_paths=(), after_publish=None):
+ """Compute privately, then publish only while source and destination are current."""
+ with subtitle_write_locks(video_path, destination, *source_paths) as states:
+ def version(path):
+ directory = os.path.normcase(os.path.realpath(os.path.dirname(path)))
+ return (states[directory].revision(path), subtitle_source_version(path))
+
+ destination_version = version(destination)
+ source_versions = {path: version(path) for path in source_paths}
+ temporary = os.path.join(os.path.dirname(destination), f'.bazarr-write-{uuid.uuid4().hex}{Path(destination).suffix}')
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
+ os.close(fd)
+ try:
+ yield temporary
+ if allow_empty and os.path.isfile(temporary) and os.path.getsize(temporary) == 0:
+ return
+ if not os.path.isfile(temporary) or os.path.getsize(temporary) == 0:
+ raise OSError('Subtitle writer did not produce a nonempty file')
+ with subtitle_write_locks(video_path, destination, *source_paths):
+ with subtitle_mutation(video_path, destination):
+ if before_publish:
+ before_publish()
+ if destination_version != version(destination):
+ raise SubtitleDestinationChanged('Subtitle changed during processing')
+ if any(expected[1] is None or expected != version(path) for path, expected in source_versions.items()):
+ raise SubtitleSourceChanged('Source subtitle changed during processing')
+ if os.path.isfile(destination):
+ shutil.copymode(destination, temporary)
+ os.replace(temporary, destination)
+ if after_publish:
+ after_publish()
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+
+
+def write_subtitle_file(video_path, destination, content, written_paths=None):
+ """Write one saver output atomically and record that exact successful path."""
+ with staged_subtitle_write(video_path, destination,
+ after_publish=(lambda: written_paths.append(destination))
+ if written_paths is not None else None) as temporary:
+ with open(temporary, 'wb') as handle:
+ handle.write(content)
+
+
+def _normalized_media_stem(path):
+ return unicodedata.normalize('NFC', os.path.splitext(os.path.basename(path))[0].lower())
+
+
+class SyncOutputOwnerIndex:
+ """Lazily map library owners once for one sequential subtitle scan.
+
+ Only indexers share this snapshot. File mutations and queued publications
+ use fresh ownership checks so a completed scan cannot authorize a later write.
+ """
+
+ def __init__(self):
+ self._loaded = False
+ self._owners = {}
+
+ @staticmethod
+ def _load_owners():
+ from app.database import database, select, TableEpisodes, TableMovies
+ from app.config import settings
+ from utilities.path_mappings import path_mappings
+
+ subfolder = settings.general.subfolder
+ custom_folder = settings.general.subfolder_custom
+ absolute_folder = (os.path.normcase(os.path.realpath(custom_folder))
+ if subfolder == 'absolute' else None)
+ owners = {}
+ for table, media_type in ((TableEpisodes, 'episode'), (TableMovies, 'movie')):
+ for row in database.execute(select(table.path, table.arr_instance_id)).all():
+ if not row.path:
+ continue
+ mapped = os.path.normcase(os.path.realpath(path_mappings.path_replace_instance(
+ row.path, row.arr_instance_id, media_type)))
+ folders = {os.path.dirname(mapped)}
+ if subfolder == 'absolute':
+ folders.add(absolute_folder)
+ elif subfolder == 'relative':
+ folders.add(os.path.normcase(os.path.realpath(os.path.join(
+ os.path.dirname(mapped), custom_folder))))
+ stem = _normalized_media_stem(mapped)
+ for folder in folders:
+ owners.setdefault((folder, stem), set()).add(mapped)
+ return owners
+
+ def is_unique(self, video, directory, conflicting_stems):
+ if not self._loaded:
+ # A failed build leaves an empty index, never a partial ownership
+ # claim or repeated full-library retries during the same scan.
+ self._loaded = True
+ self._owners = self._load_owners()
+ owned = False
+ expected_owner = {video}
+ for stem in conflicting_stems:
+ owners = self._owners.get((directory, stem))
+ if owners:
+ if owners != expected_owner:
+ return False
+ owned = True
+ return owned
+
+
+def sync_output_owner_is_unique(video_path, source_path, ownership_index=None):
+ """Prove ownership before moving or replacing an exact generated variant."""
+ from subliminal_patch.core import VIDEO_EXTENSIONS
+
+ video = os.path.normcase(os.path.realpath(video_path))
+ directory = os.path.normcase(os.path.realpath(os.path.dirname(source_path)))
+ media_directory = os.path.dirname(video)
+
+ stem = _normalized_media_stem
+ if stem(source_path) != stem(video) and not stem(source_path).startswith(stem(video) + '.'):
+ return False
+ conflicting_stems = {stem(video), stem(source_path)}
+ try:
+ with os.scandir(media_directory) as entries:
+ local_videos = {os.path.normcase(os.path.realpath(entry.path)) for entry in entries
+ if entry.is_file() and entry.name.lower().endswith(VIDEO_EXTENSIONS)}
+ if any(path != video and stem(path) in conflicting_stems for path in local_videos):
+ return False
+ if directory == media_directory:
+ return video in local_videos
+
+ # Separate media directories can share a custom subtitle folder. Keep
+ # all mapped owners, amortizing that lookup only within an explicit scan.
+ if ownership_index is None:
+ ownership_index = SyncOutputOwnerIndex()
+ return ownership_index.is_unique(video, directory, conflicting_stems)
+ except (OSError, ValueError):
+ return False
+ except Exception:
+ logging.exception('BAZARR unable to verify generated subtitle ownership')
+ return False
+
+
+def quarantine_sync_outputs(video_path, source_path):
+ """Preserve proven generated siblings under names no subtitle scan accepts."""
+ if is_sync_engine_output(source_path) or not sync_output_owner_is_unique(video_path, source_path):
+ return
+ from subtitles.indexer.utils import add_sync_engine_outputs
+
+ directory = os.path.dirname(source_path)
+ with subtitle_write_lock(video_path, directory) as state:
+ owned = add_sync_engine_outputs(directory, {}, video_path=video_path)
+ for engine in SYNC_ENGINES:
+ output = engine_output_path(source_path, engine)
+ if output.name not in owned:
+ continue
+ try:
+ if not stat.S_ISREG(output.lstat().st_mode):
+ continue
+ except FileNotFoundError:
+ continue
+ fd, quarantine = tempfile.mkstemp(prefix=f'.bazarr-sync-obsolete-{output.name[:40]}-', suffix='.bak', dir=directory)
+ os.close(fd)
+ try:
+ os.replace(output, quarantine)
+ except BaseException:
+ os.unlink(quarantine)
+ raise
+ state.changed(output)
+
+
+def quarantine_sync_outputs_after_mutation(video_path, source_path):
+ """Report cleanup failure separately from an already completed file change."""
+ try:
+ quarantine_sync_outputs(video_path, source_path)
+ except OSError as exc:
+ logging.error(
+ 'BAZARR subtitle change completed for %s, but obsolete generated subtitle cleanup failed (%s). '
+ 'Previous generated files may remain available.', source_path, type(exc).__name__)
+
+
+class SubtitleSourceChanged(Exception):
+ """The upload was replaced or deleted while its sync was queued or running."""
+
+
+class SubtitleDestinationChanged(Exception):
+ """A user changed or deleted this engine's destination after enqueueing."""
SYNC_ENGINES = ('ffsubsync', 'autosubsync', 'alass')
@@ -46,6 +374,9 @@
REASON_ENGINE_DECLINED = 'engine_declined'
REASON_RESULT_REJECTED = 'result_rejected'
REASON_ENGINE_FAILED = 'engine_failed'
+REASON_SOURCE_CHANGED = 'source_changed'
+REASON_DESTINATION_CHANGED = 'destination_changed'
+REASON_DESTINATION_AMBIGUOUS = 'destination_ambiguous'
ENGINE_LABELS = {
'ffsubsync': 'FFsubsync',
@@ -409,9 +740,10 @@ def _existing_keep_all_output_is_current(self, srt_path, output_path, engine):
return output_stat.st_size > 0 and output_stat.st_mtime_ns >= source_stat.st_mtime_ns
- def run(self, srt_path, output_mode, enabled_engines, execute_engine, force_sync=False):
+ def run(self, srt_path, output_mode, enabled_engines, execute_engine, force_sync=False,
+ source_version=None, before_publish=None, publication_lock=None, after_publish=None):
output_mode = normalize_output_mode(output_mode)
- result = SyncRunResult(source_path=srt_path, output_mode=output_mode)
+ result = self.result = SyncRunResult(source_path=srt_path, output_mode=output_mode)
if is_sync_engine_output(srt_path):
result.results.append(SyncEngineResult(
@@ -423,11 +755,24 @@ def run(self, srt_path, output_mode, enabled_engines, execute_engine, force_sync
return result
for engine in normalize_enabled_engines(enabled_engines):
+ if source_version is not None and not source_is_unchanged(srt_path, source_version):
+ result.results.append(SyncEngineResult(
+ engine=engine, status=RESULT_SKIPPED, reason=REASON_SOURCE_CHANGED,
+ message='The uploaded subtitle was replaced or deleted.',
+ ))
+ break
final_engine_output_path = engine_output_path(srt_path, engine)
- output_path = (
- final_engine_output_path if output_mode == OUTPUT_MODE_KEEP_ALL
- else temporary_engine_output_path(srt_path, engine)
- )
+ output_path = temporary_engine_output_path(srt_path, engine)
+
+ if (output_mode == OUTPUT_MODE_KEEP_ALL and isinstance(source_version, SubtitlePublication)
+ and not source_version.destination_unchanged(final_engine_output_path)):
+ result.results.append(SyncEngineResult(
+ engine=engine, status=RESULT_SKIPPED,
+ reason=(REASON_DESTINATION_CHANGED if source_version.destination_owned(final_engine_output_path)
+ else REASON_DESTINATION_AMBIGUOUS),
+ message='The synchronized subtitle destination changed or its owner is ambiguous.',
+ ))
+ continue
if self.failure_store.should_skip(srt_path, engine) and not force_sync:
result.results.append(SyncEngineResult(
@@ -462,10 +807,26 @@ def run(self, srt_path, output_mode, enabled_engines, execute_engine, force_sync
generated_path = str(output_path)
final_output_path = output_path
- if output_mode == OUTPUT_MODE_OVERWRITE:
- os.replace(str(output_path), srt_path)
- final_output_path = Path(srt_path)
- generated_path = None
+ with publication_lock or nullcontext() as state:
+ if before_publish:
+ before_publish()
+ if source_version is not None and not source_is_unchanged(srt_path, source_version):
+ raise SubtitleSourceChanged()
+ if (output_mode == OUTPUT_MODE_KEEP_ALL and isinstance(source_version, SubtitlePublication)
+ and not source_version.destination_unchanged(final_engine_output_path)):
+ raise SubtitleDestinationChanged()
+ if output_mode == OUTPUT_MODE_OVERWRITE:
+ os.replace(str(output_path), srt_path)
+ final_output_path = Path(srt_path)
+ generated_path = None
+ else:
+ os.replace(str(output_path), str(final_engine_output_path))
+ final_output_path = final_engine_output_path
+ generated_path = str(final_engine_output_path)
+ if hasattr(state, 'changed'):
+ state.changed(final_output_path)
+ if after_publish:
+ after_publish()
self.failure_store.record_success(srt_path, engine)
result.results.append(SyncEngineResult(
@@ -479,6 +840,24 @@ def run(self, srt_path, output_mode, enabled_engines, execute_engine, force_sync
if output_mode == OUTPUT_MODE_OVERWRITE:
break
+ except SubtitleSourceChanged:
+ if output_path.is_file():
+ output_path.unlink()
+ result.results.append(SyncEngineResult(
+ engine=engine, status=RESULT_SKIPPED, reason=REASON_SOURCE_CHANGED,
+ message='The uploaded subtitle was replaced or deleted.',
+ ))
+ break
+ except SubtitleDestinationChanged:
+ if output_path.is_file():
+ output_path.unlink()
+ result.results.append(SyncEngineResult(
+ engine=engine, status=RESULT_SKIPPED,
+ reason=(REASON_DESTINATION_CHANGED if source_version.destination_owned(final_engine_output_path)
+ else REASON_DESTINATION_AMBIGUOUS),
+ message='The synchronized subtitle destination changed or its owner is ambiguous.',
+ ))
+ continue
except MissingSyncEngineError as exc:
logging.warning('BAZARR %s sync engine skipped: %s', engine, exc)
result.results.append(SyncEngineResult(
@@ -519,6 +898,11 @@ def run(self, srt_path, output_mode, enabled_engines, execute_engine, force_sync
))
except Exception as exc:
+ if before_publish:
+ from app.jobs_queue import JobCancelled
+ if isinstance(exc, JobCancelled):
+ self._discard_engine_output(srt_path, engine, output_path, exc, record=False)
+ raise
logging.exception('BAZARR %s sync engine failed for %s', engine, srt_path)
self._discard_engine_output(srt_path, engine, output_path, exc)
result.results.append(SyncEngineResult(
diff --git a/bazarr/subtitles/tools/subsyncer.py b/bazarr/subtitles/tools/subsyncer.py
index 69029f056a..8ada6648d1 100644
--- a/bazarr/subtitles/tools/subsyncer.py
+++ b/bazarr/subtitles/tools/subsyncer.py
@@ -22,6 +22,10 @@
normalize_enabled_engines,
normalize_output_mode,
validate_engine_result,
+ subtitle_write_lock,
+ SubtitlePublication,
+ subtitle_source_version,
+ quarantine_sync_outputs_after_mutation,
)
from languages.get_languages import audio_language_from_name, language_from_alpha2
from utilities.path_mappings import path_mappings
@@ -523,7 +527,7 @@ def _log_sync_history(self, success_result, output_mode, srt_lang, hi, forced,
def sync(self, video_path, srt_path, srt_lang, hi, forced,
max_offset_seconds, no_fix_framerate, gss, reference=None, sonarr_series_id=None, sonarr_episode_id=None,
radarr_id=None, progress_callback=None, job_id=None, force_sync=False, output_mode=None,
- enabled_engines=None, write_history=True, arr_instance_id=None):
+ enabled_engines=None, write_history=True, arr_instance_id=None, source_version=None):
self.reference = video_path
self.srtin = srt_path
self.progress_callback = progress_callback
@@ -586,13 +590,33 @@ def execute_engine(engine, output_path):
return raw_result
runner = SubsyncEngineRunner()
- self.sync_result = runner.run(
- srt_path=self.srtin,
- output_mode=output_mode,
- enabled_engines=enabled_engines,
- execute_engine=execute_engine,
- force_sync=force_sync,
- )
+ publication_lock = subtitle_write_lock(video_path, os.path.dirname(srt_path))
+ publication = source_version
+ if publication is None:
+ with publication_lock:
+ publication = SubtitlePublication(video_path, srt_path, subtitle_source_version(srt_path))
+
+ def publish():
+ self._report_progress('Saving synchronized subtitle', None, None)
+
+ try:
+ self.sync_result = runner.run(
+ srt_path=self.srtin,
+ output_mode=output_mode,
+ enabled_engines=enabled_engines,
+ execute_engine=execute_engine,
+ force_sync=force_sync,
+ source_version=publication,
+ before_publish=publish,
+ publication_lock=publication_lock,
+ after_publish=(lambda: quarantine_sync_outputs_after_mutation(video_path, srt_path))
+ if output_mode == OUTPUT_MODE_OVERWRITE and source_version is None else None,
+ )
+ finally:
+ # Preserve successful destinations even if a later engine is cancelled.
+ self.sync_result = getattr(runner, 'result', self.sync_result)
+ if source_version is None:
+ publication.release()
if settings.subsync.debug:
return self.sync_result
diff --git a/bazarr/subtitles/tools/translate/core/translator_utils.py b/bazarr/subtitles/tools/translate/core/translator_utils.py
index 86d5341df6..0732294326 100644
--- a/bazarr/subtitles/tools/translate/core/translator_utils.py
+++ b/bazarr/subtitles/tools/translate/core/translator_utils.py
@@ -111,12 +111,38 @@ def add_translator_info(dest_srt_file, info):
f.write(srt.compose(subtitles))
-def get_description(media_type, radarr_id, sonarr_series_id):
+# The callers do not agree on how to spell this: the manual translate endpoint
+# sends "movie" while the batch and mass-operation paths send "movies". Both mean
+# the same thing, and matching only one of them silently produced an empty title
+# and an empty prompt description for every movie translated from its own page.
+MOVIE_MEDIA_TYPES = ('movie', 'movies')
+
+
+def is_movie_media_type(media_type) -> bool:
+ return str(media_type or '').strip().lower() in MOVIE_MEDIA_TYPES
+
+
+def _scope(statement, table, arr_instance_id):
+ """Narrow a lookup to the owning arr instance (#156).
+
+ ``radarrId`` and ``sonarrSeriesId`` are unique only together with
+ ``arr_instance_id``, so an unscoped query can return a sibling instance's
+ media. Callers that genuinely have no owner (older paths, and the tests that
+ seed a single instance) pass None and keep the previous behaviour.
+ """
+ if arr_instance_id is None:
+ return statement
+ return statement.where(table.arr_instance_id == arr_instance_id)
+
+
+def get_description(media_type, radarr_id, sonarr_series_id, arr_instance_id=None):
try:
- if media_type == 'movies':
+ if is_movie_media_type(media_type):
movie = database.execute(
- select(TableMovies.title, TableMovies.imdbId, TableMovies.year, TableMovies.overview)
- .where(TableMovies.radarrId == radarr_id)
+ _scope(
+ select(TableMovies.title, TableMovies.imdbId, TableMovies.year, TableMovies.overview)
+ .where(TableMovies.radarrId == radarr_id),
+ TableMovies, arr_instance_id)
).first()
if movie:
@@ -128,8 +154,10 @@ def get_description(media_type, radarr_id, sonarr_series_id):
else:
series = database.execute(
- select(TableShows.title, TableShows.imdbId, TableShows.year, TableShows.overview)
- .where(TableShows.sonarrSeriesId == sonarr_series_id)
+ _scope(
+ select(TableShows.title, TableShows.imdbId, TableShows.year, TableShows.overview)
+ .where(TableShows.sonarrSeriesId == sonarr_series_id),
+ TableShows, arr_instance_id)
).first()
if series:
@@ -147,15 +175,17 @@ def get_title(
media_type: str,
radarr_id: Union[int, None] = None,
sonarr_series_id: Union[int, None] = None,
- sonarr_episode_id: Union[int, None] = None
+ sonarr_episode_id: Union[int, None] = None,
+ arr_instance_id: Union[int, None] = None
) -> str:
try:
- if media_type == "movies":
+ if is_movie_media_type(media_type):
if radarr_id is None:
return ""
movie_row = database.execute(
- select(TableMovies.title).where(TableMovies.radarrId == radarr_id)
+ _scope(select(TableMovies.title).where(TableMovies.radarrId == radarr_id),
+ TableMovies, arr_instance_id)
).first()
if movie_row is None:
@@ -176,7 +206,8 @@ def get_title(
return ""
series_row = database.execute(
- select(TableShows.title).where(TableShows.sonarrSeriesId == sonarr_series_id)
+ _scope(select(TableShows.title).where(TableShows.sonarrSeriesId == sonarr_series_id),
+ TableShows, arr_instance_id)
).first()
if series_row is None:
@@ -193,8 +224,10 @@ def get_title(
# If episode ID is provided, get episode details and format as "Series - S##E## - Episode Title"
if sonarr_episode_id is not None:
episode_row = database.execute(
- select(TableEpisodes.season, TableEpisodes.episode, TableEpisodes.title)
- .where(TableEpisodes.sonarrEpisodeId == sonarr_episode_id)
+ _scope(
+ select(TableEpisodes.season, TableEpisodes.episode, TableEpisodes.title)
+ .where(TableEpisodes.sonarrEpisodeId == sonarr_episode_id),
+ TableEpisodes, arr_instance_id)
).first()
if episode_row is not None:
diff --git a/bazarr/subtitles/tools/translate/main.py b/bazarr/subtitles/tools/translate/main.py
index ce5a423e47..4a22a4b797 100644
--- a/bazarr/subtitles/tools/translate/main.py
+++ b/bazarr/subtitles/tools/translate/main.py
@@ -12,7 +12,7 @@
from languages.get_languages import alpha3_from_alpha2
from app.config import settings
from app.jobs_queue import jobs_queue
-from subtitles.indexer.utils import get_external_subtitles_path
+from subtitles.indexer.utils import get_subtitle_destination_path
def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, forced, hi,
@@ -25,7 +25,8 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo
jobs_queue.add_job_from_function(
(lambda t: f'Translating {t} ({from_lang.upper()} to {to_lang.upper()})' if t else
f'Translating {from_lang.upper()} to {to_lang.upper()}')(
- get_title(media_type, radarr_id, sonarr_series_id, sonarr_episode_id)),
+ get_title(media_type, radarr_id, sonarr_series_id, sonarr_episode_id,
+ arr_instance_id)),
is_progress=True)
return
@@ -46,7 +47,9 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo
hi_tag=hi
)
- dest_srt_file = get_external_subtitles_path(
+ # Resolved as a write target, not a lookup: the file does not exist yet on a
+ # first translation, and the lookup helper answered None for custom folders.
+ dest_srt_file = get_subtitle_destination_path(
file=video_path,
subtitle=os.path.basename(dest_srt_file_if_alongside_video)
)
@@ -68,7 +71,8 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo
hi=hi,
sonarr_series_id=sonarr_series_id,
sonarr_episode_id=sonarr_episode_id,
- radarr_id=radarr_id
+ radarr_id=radarr_id,
+ arr_instance_id=arr_instance_id
)
logging.debug(f'Created translator instance: {translator.__class__.__name__}') # noqa: G004
@@ -105,11 +109,12 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo
logging.exception("BAZARR combine-after-translate failed for %s", video_path)
# Get current job name (which batch.py already set with title) and mark as done
+ completion_label = 'Partially translated' if getattr(translator, 'partial_error', None) else 'Translated'
current_name = jobs_queue.get_job_name(job_id)
if current_name and 'Translating' in current_name:
- done_name = current_name.replace('Translating', 'Translated')
+ done_name = current_name.replace('Translating', completion_label)
else:
- done_name = f'Translated {from_lang.upper()} \u2192 {to_lang.upper()} using {translator_label}'
+ done_name = f'{completion_label} {from_lang.upper()} \u2192 {to_lang.upper()} using {translator_label}'
jobs_queue.update_job_name(job_id=job_id, new_job_name=done_name)
return result
diff --git a/bazarr/subtitles/tools/translate/services/gemini_translator.py b/bazarr/subtitles/tools/translate/services/gemini_translator.py
index 7a2e7ada94..b653c16cc2 100644
--- a/bazarr/subtitles/tools/translate/services/gemini_translator.py
+++ b/bazarr/subtitles/tools/translate/services/gemini_translator.py
@@ -12,6 +12,7 @@
import srt
import pysubs2
+from subtitles.tools.subsync_engines import staged_subtitle_write
import requests
import unicodedata as ud
from collections import Counter
@@ -23,7 +24,7 @@
from radarr.history import history_log_movie
from utilities.path_mappings import path_mappings # noqa: F401
from subtitles.processing import ProcessSubtitlesResult # noqa: F401
-from app.jobs_queue import jobs_queue
+from app.jobs_queue import JobCancelled, jobs_queue
from languages.get_languages import alpha3_from_alpha2, language_from_alpha2, language_from_alpha3 # noqa: F401
from ..core.translator_utils import add_translator_info, get_description, create_process_result
@@ -45,13 +46,17 @@ class SubtitleObject(typing.TypedDict):
class GeminiTranslatorService:
def __init__(self, source_srt_file, dest_srt_file, to_lang, media_type, sonarr_series_id, sonarr_episode_id,
- radarr_id, forced, hi, video_path, from_lang, orig_to_lang, **kwargs):
+ radarr_id, forced, hi, video_path, from_lang, orig_to_lang,
+ arr_instance_id=None, **kwargs):
self.source_srt_file = source_srt_file
self.dest_srt_file = dest_srt_file
self.to_lang = to_lang
self.media_type = media_type
self.sonarr_series_id = sonarr_series_id
self.radarr_id = radarr_id
+ # The owning arr instance (#156): radarrId and sonarrSeriesId are only
+ # unique together with it, so every media lookup below carries it.
+ self.arr_instance_id = arr_instance_id
self.from_lang = from_lang
self.video_path = video_path
self.forced = forced
@@ -95,23 +100,31 @@ def translate(self, job_id):
self.gemini_api_key = self.current_api_key
self.target_language = language_from_alpha3(self.to_lang)
self.input_file = self.source_srt_file
- self.output_file = self.dest_srt_file
self.model_name = settings.translator.gemini_model
self.batch_size = self._get_batch_size()
- self.description = get_description(self.media_type, self.radarr_id, self.sonarr_series_id)
+ self.description = get_description(self.media_type, self.radarr_id, self.sonarr_series_id,
+ arr_instance_id=self.arr_instance_id)
if self.input_file:
self.progress_file = os.path.join(os.path.dirname(self.input_file), f".{os.path.basename(self.input_file)}.progress")
- self._check_saved_progress()
-
try:
- self._translate_with_gemini()
- add_translator_info(self.dest_srt_file, f"# Subtitles translated with {settings.translator.gemini_model} # ")
+ with staged_subtitle_write(self.video_path, self.dest_srt_file,
+ source_paths=(self.source_srt_file,),
+ before_publish=lambda: jobs_queue.update_job_progress(job_id=job_id)) as temporary:
+ self.output_file = temporary
+ self._check_saved_progress()
+ self._translate_with_gemini()
+ add_translator_info(temporary, f"# Subtitles translated with {settings.translator.gemini_model} # ")
+ except JobCancelled:
+ raise
except Exception as e:
jobs_queue.update_job_progress(job_id=job_id, progress_message=f'Gemini translation error: {str(e)}')
raise
+ except JobCancelled:
+ self._clear_progress()
+ raise
except Exception as e:
logger.error(f'BAZARR encountered an error translating with Gemini: {str(e)}') # noqa: G004
raise
@@ -177,6 +190,8 @@ def _check_saved_progress(self):
if saved_line > 1 and self.start_line == 1:
os.remove(self.output_file)
+ except JobCancelled:
+ raise
except Exception as e:
jobs_queue.update_job_progress(job_id=self.job_id, progress_message=f"Error reading progress file: {e}")
@@ -192,7 +207,7 @@ def _save_progress(self, line):
jobs_queue.update_job_progress(job_id=self.job_id, progress_message=f"Failed to save progress: {e}")
def _clear_progress(self):
- """Clear the progress file on successful completion"""
+ """Remove saved progress after completion, failure, or cancellation."""
if self.progress_file and os.path.exists(self.progress_file):
try:
os.remove(self.progress_file)
@@ -362,6 +377,7 @@ def _process_batch(
}
try:
+ jobs_queue.update_job_progress(job_id=self.job_id)
response = requests.request("POST", url, headers=headers, data=payload)
response.raise_for_status() # Raise an exception for bad status codes
@@ -398,6 +414,8 @@ def clean_json_string(json_string):
return self.current_progress
+ except JobCancelled:
+ raise
except Exception as e:
response = getattr(e, "response", None)
if self._is_rate_limited_response(response):
@@ -538,15 +556,20 @@ def _translate_with_gemini(self):
# Clear progress file on successful completion
self._clear_progress()
+ except JobCancelled:
+ raise
except Exception as e:
logger.error(f'BAZARR encountered an error translating with Gemini: {str(e)}') # noqa: G004
jobs_queue.update_job_progress(job_id=self.job_id, progress_value=total,
progress_message=f'Gemini translation failed: {str(e)}')
- self._clear_progress()
- if self.output_file and os.path.exists(self.output_file):
- try:
- if os.path.getsize(self.output_file) == 0:
- os.remove(self.output_file)
- except OSError:
- pass
- raise e
+ raise
+ finally:
+ try:
+ self._clear_progress()
+ finally:
+ if self.output_file and os.path.exists(self.output_file):
+ try:
+ if os.path.getsize(self.output_file) == 0:
+ os.remove(self.output_file)
+ except OSError:
+ pass
diff --git a/bazarr/subtitles/tools/translate/services/google_translator.py b/bazarr/subtitles/tools/translate/services/google_translator.py
index ec5ecd7515..99e97e82a7 100644
--- a/bazarr/subtitles/tools/translate/services/google_translator.py
+++ b/bazarr/subtitles/tools/translate/services/google_translator.py
@@ -2,6 +2,7 @@
import logging
import pysubs2
+from subtitles.tools.subsync_engines import staged_subtitle_write
from retry.api import retry
from app.config import settings # noqa: F401
@@ -23,7 +24,7 @@ class GoogleTranslatorService:
def __init__(self, source_srt_file, dest_srt_file, lang_obj, to_lang, from_lang, media_type,
video_path, orig_to_lang, forced, hi, sonarr_series_id, sonarr_episode_id,
- radarr_id):
+ radarr_id, arr_instance_id=None):
self.source_srt_file = source_srt_file
self.dest_srt_file = dest_srt_file
self.lang_obj = lang_obj
@@ -37,6 +38,9 @@ def __init__(self, source_srt_file, dest_srt_file, lang_obj, to_lang, from_lang,
self.sonarr_series_id = sonarr_series_id
self.sonarr_episode_id = sonarr_episode_id
self.radarr_id = radarr_id
+ # The owning arr instance (#156): radarrId and sonarrSeriesId are only
+ # unique together with it, so every media lookup below carries it.
+ self.arr_instance_id = arr_instance_id
self.language_code_convert_dict = {
'he': 'iw',
'zh': 'zh-CN',
@@ -45,67 +49,71 @@ def __init__(self, source_srt_file, dest_srt_file, lang_obj, to_lang, from_lang,
def translate(self, job_id):
try:
- subs = pysubs2.load(self.source_srt_file, encoding='utf-8')
- subs.remove_miscellaneous_events()
- lines_list = [x.plaintext for x in subs]
- lines_list_len = len(lines_list)
+ with staged_subtitle_write(self.video_path, self.dest_srt_file,
+ source_paths=(self.source_srt_file,),
+ before_publish=lambda: jobs_queue.update_job_progress(job_id=job_id),
+ allow_empty=True) as temporary:
+ subs = pysubs2.load(self.source_srt_file, encoding='utf-8')
+ subs.remove_miscellaneous_events()
+ lines_list = [x.plaintext for x in subs]
+ lines_list_len = len(lines_list)
+
+ jobs_queue.update_job_progress(job_id=job_id, progress_max=lines_list_len,
+ progress_message=self.source_srt_file)
+
+ translated_lines = []
+ logger.debug(f'starting translation for {self.source_srt_file}') # noqa: G004
+
+ def translate_line(line_id, subtitle_line):
+ try:
+ translated_text = self._translate_text(subtitle_line, job_id)
+ translated_lines.append({'id': line_id, 'line': translated_text})
+ except TranslationNotFound:
+ logger.debug(f'Unable to translate line {subtitle_line}') # noqa: G004
+ translated_lines.append({'id': line_id, 'line': subtitle_line})
+ finally:
+ jobs_queue.update_job_progress(job_id=job_id, progress_value=len(translated_lines))
+
+ logger.debug(f'BAZARR is sending {lines_list_len} blocks to Google Translate') # noqa: G004
+ pool = ThreadPoolExecutor(max_workers=10)
+ futures = []
+ for i, line in enumerate(lines_list):
+ future = pool.submit(translate_line, i, line)
+ futures.append(future)
+ pool.shutdown(wait=True)
+ for future in futures:
+ try:
+ future.result()
+ except Exception as e:
+ logger.error(f"Error in translation task: {e}") # noqa: G004
+
+ for i, line in enumerate(translated_lines):
+ lines_list[line['id']] = line['line']
+
+ logger.debug(f'BAZARR saving translated subtitles to {self.dest_srt_file}') # noqa: G004
+ for i, line in enumerate(subs):
+ try:
+ if lines_list[i]:
+ line.plaintext = lines_list[i]
+ else:
+ # we assume that there was nothing to translate if Google returns None. ex.: "♪♪"
+ continue
+ except IndexError:
+ logger.error(f'BAZARR is unable to translate malformed subtitles: {self.source_srt_file}') # noqa: G004
+ jobs_queue.update_job_progress(job_id=job_id,
+ progress_message=f'Translation failed: Unable to translate '
+ f'malformed subtitles for {self.source_srt_file}')
+ raise
- jobs_queue.update_job_progress(job_id=job_id, progress_max=lines_list_len,
- progress_message=self.source_srt_file)
-
- translated_lines = []
- logger.debug(f'starting translation for {self.source_srt_file}') # noqa: G004
-
- def translate_line(line_id, subtitle_line):
- try:
- translated_text = self._translate_text(subtitle_line, job_id)
- translated_lines.append({'id': line_id, 'line': translated_text})
- except TranslationNotFound:
- logger.debug(f'Unable to translate line {subtitle_line}') # noqa: G004
- translated_lines.append({'id': line_id, 'line': subtitle_line})
- finally:
- jobs_queue.update_job_progress(job_id=job_id, progress_value=len(translated_lines))
-
- logger.debug(f'BAZARR is sending {lines_list_len} blocks to Google Translate') # noqa: G004
- pool = ThreadPoolExecutor(max_workers=10)
- futures = []
- for i, line in enumerate(lines_list):
- future = pool.submit(translate_line, i, line)
- futures.append(future)
- pool.shutdown(wait=True)
- for future in futures:
- try:
- future.result()
- except Exception as e:
- logger.error(f"Error in translation task: {e}") # noqa: G004
-
- for i, line in enumerate(translated_lines):
- lines_list[line['id']] = line['line']
-
- logger.debug(f'BAZARR saving translated subtitles to {self.dest_srt_file}') # noqa: G004
- for i, line in enumerate(subs):
try:
- if lines_list[i]:
- line.plaintext = lines_list[i]
- else:
- # we assume that there was nothing to translate if Google returns None. ex.: "♪♪"
- continue
- except IndexError:
- logger.error(f'BAZARR is unable to translate malformed subtitles: {self.source_srt_file}') # noqa: G004
+ subs.save(temporary)
+ add_translator_info(temporary, f"# Subtitles translated with Google Translate # ") # noqa: F541
+ except OSError:
+ logger.error(f'BAZARR is unable to save translated subtitles to {self.dest_srt_file}') # noqa: G004
jobs_queue.update_job_progress(job_id=job_id,
- progress_message=f'Translation failed: Unable to translate '
- f'malformed subtitles for {self.source_srt_file}')
- raise
-
- try:
- subs.save(self.dest_srt_file)
- add_translator_info(self.dest_srt_file, f"# Subtitles translated with Google Translate # ") # noqa: F541
- except OSError:
- logger.error(f'BAZARR is unable to save translated subtitles to {self.dest_srt_file}') # noqa: G004
- jobs_queue.update_job_progress(job_id=job_id,
- progress_message=f'Translation failed: Unable to save translated '
- f'subtitles to {self.dest_srt_file}')
- raise OSError
+ progress_message=f'Translation failed: Unable to save translated '
+ f'subtitles to {self.dest_srt_file}')
+ raise OSError
message = f"{language_from_alpha2(self.from_lang)} subtitles translated to {language_from_alpha3(self.to_lang)}."
result = create_process_result(message, self.video_path, self.orig_to_lang, self.forced, self.hi, self.dest_srt_file, self.media_type)
diff --git a/bazarr/subtitles/tools/translate/services/lingarr_translator.py b/bazarr/subtitles/tools/translate/services/lingarr_translator.py
index 0c98f9428f..88a14b6346 100644
--- a/bazarr/subtitles/tools/translate/services/lingarr_translator.py
+++ b/bazarr/subtitles/tools/translate/services/lingarr_translator.py
@@ -2,6 +2,7 @@
import logging
import pysubs2
+from subtitles.tools.subsync_engines import staged_subtitle_write
import requests
from retry.api import retry
@@ -30,7 +31,7 @@ class LingarrAuthError(Exception):
class LingarrTranslatorService:
def __init__(self, source_srt_file, dest_srt_file, lang_obj, to_lang, from_lang, media_type,
video_path, orig_to_lang, forced, hi, sonarr_series_id, sonarr_episode_id,
- radarr_id):
+ radarr_id, arr_instance_id=None):
self.source_srt_file = source_srt_file
self.dest_srt_file = dest_srt_file
self.lang_obj = lang_obj
@@ -44,6 +45,9 @@ def __init__(self, source_srt_file, dest_srt_file, lang_obj, to_lang, from_lang,
self.sonarr_series_id = sonarr_series_id
self.sonarr_episode_id = sonarr_episode_id
self.radarr_id = radarr_id
+ # The owning arr instance (#156): radarrId and sonarrSeriesId are only
+ # unique together with it, so every media lookup below carries it.
+ self.arr_instance_id = arr_instance_id
self.language_code_convert_dict = {
'zh': 'zh-CN',
'zt': 'zh-TW',
@@ -52,44 +56,48 @@ def __init__(self, source_srt_file, dest_srt_file, lang_obj, to_lang, from_lang,
def translate(self, job_id=None):
try:
- jobs_queue.update_job_progress(job_id=job_id, progress_max=1, progress_message=self.source_srt_file)
-
- subs = pysubs2.load(self.source_srt_file, encoding='utf-8')
- lines_list = [x.plaintext for x in subs]
- lines_list_len = len(lines_list)
-
- if lines_list_len == 0:
- logger.debug('No lines to translate in subtitle file')
- return self.dest_srt_file
-
- logger.debug(f'Starting translation for {self.source_srt_file}') # noqa: G004
- translated_lines = self._translate_content(lines_list, job_id=job_id)
-
- if translated_lines is None:
- logger.error(f'Translation failed for {self.source_srt_file}') # noqa: G004
- jobs_queue.update_job_progress(job_id=job_id,
- progress_message=f'Translation failed for {self.source_srt_file}')
- raise RuntimeError(f'Translation failed for {self.source_srt_file}')
-
- logger.debug(f'BAZARR saving Lingarr translated subtitles to {self.dest_srt_file}') # noqa: G004
- translation_map = {}
- for item in translated_lines:
- if isinstance(item, dict) and 'position' in item and 'line' in item:
- translation_map[item['position']] = item['line']
-
- for i, line in enumerate(subs):
- if i in translation_map and translation_map[i]:
- line.text = translation_map[i]
-
- try:
- subs.save(self.dest_srt_file)
- add_translator_info(self.dest_srt_file, f"# Subtitles translated with Lingarr # ") # noqa: F541
- except OSError:
- logger.error(f'BAZARR is unable to save translated subtitles to {self.dest_srt_file}') # noqa: G004
- jobs_queue.update_job_progress(job_id=job_id,
- progress_message=f'Translation failed: Unable to save translated '
- f'subtitles to {self.dest_srt_file}')
- raise OSError
+ with staged_subtitle_write(self.video_path, self.dest_srt_file,
+ source_paths=(self.source_srt_file,),
+ before_publish=lambda: jobs_queue.update_job_progress(job_id=job_id),
+ allow_empty=True) as temporary:
+ jobs_queue.update_job_progress(job_id=job_id, progress_max=1, progress_message=self.source_srt_file)
+
+ subs = pysubs2.load(self.source_srt_file, encoding='utf-8')
+ lines_list = [x.plaintext for x in subs]
+ lines_list_len = len(lines_list)
+
+ if lines_list_len == 0:
+ logger.debug('No lines to translate in subtitle file')
+ return self.dest_srt_file
+
+ logger.debug(f'Starting translation for {self.source_srt_file}') # noqa: G004
+ translated_lines = self._translate_content(lines_list, job_id=job_id)
+
+ if translated_lines is None:
+ logger.error(f'Translation failed for {self.source_srt_file}') # noqa: G004
+ jobs_queue.update_job_progress(job_id=job_id,
+ progress_message=f'Translation failed for {self.source_srt_file}')
+ raise RuntimeError(f'Translation failed for {self.source_srt_file}')
+
+ logger.debug(f'BAZARR saving Lingarr translated subtitles to {self.dest_srt_file}') # noqa: G004
+ translation_map = {}
+ for item in translated_lines:
+ if isinstance(item, dict) and 'position' in item and 'line' in item:
+ translation_map[item['position']] = item['line']
+
+ for i, line in enumerate(subs):
+ if i in translation_map and translation_map[i]:
+ line.text = translation_map[i]
+
+ try:
+ subs.save(temporary)
+ add_translator_info(temporary, f"# Subtitles translated with Lingarr # ") # noqa: F541
+ except OSError:
+ logger.error(f'BAZARR is unable to save translated subtitles to {self.dest_srt_file}') # noqa: G004
+ jobs_queue.update_job_progress(job_id=job_id,
+ progress_message=f'Translation failed: Unable to save translated '
+ f'subtitles to {self.dest_srt_file}')
+ raise OSError
message = (f"{language_from_alpha2(self.from_lang)} subtitles translated to "
f"{language_from_alpha3(self.to_lang)} using Lingarr.")
@@ -139,7 +147,8 @@ def _translate_content(self, lines_list, job_id):
media_type=self.media_type,
radarr_id=self.radarr_id,
sonarr_series_id=self.sonarr_series_id,
- sonarr_episode_id=self.sonarr_episode_id
+ sonarr_episode_id=self.sonarr_episode_id,
+ arr_instance_id=self.arr_instance_id
)
if self.media_type == 'episode':
diff --git a/bazarr/subtitles/tools/translate/services/openrouter_translator.py b/bazarr/subtitles/tools/translate/services/openrouter_translator.py
index f58b3dbc01..a56940d511 100644
--- a/bazarr/subtitles/tools/translate/services/openrouter_translator.py
+++ b/bazarr/subtitles/tools/translate/services/openrouter_translator.py
@@ -1,8 +1,10 @@
# coding=utf-8
+import re
import time
import logging
import pysubs2
+from subtitles.tools.subsync_engines import staged_subtitle_write, SubtitleDestinationChanged
import requests
from typing import Optional, List, Dict, Any
@@ -14,13 +16,99 @@
from radarr.history import history_log_movie
from sonarr.history import history_log
from app.event_handler import show_progress, hide_progress, show_message
-from app.jobs_queue import jobs_queue
+from app.jobs_queue import jobs_queue, JobCancelled
from ..core.translator_utils import add_translator_info, create_process_result, get_title
from .auth import get_translator_auth_headers
logger = logging.getLogger(__name__)
+PROVIDER_ROUTING_VALUES = ('throughput', 'nitro', 'price', 'floor', 'latency', 'default')
+DEFAULT_PROVIDER_ROUTING = 'throughput'
+# Sidecars before this version forward provider.sort to OpenRouter verbatim, which
+# rejects nitro, floor and default; they get the plain sort each value stands for.
+ROUTING_SHORTCUTS_MIN_SIDECAR = (1, 3, 4)
+ROUTING_PLAIN_SORT = {'nitro': 'throughput', 'floor': 'price', 'default': 'throughput'}
+SIDECAR_VERSION_CACHE_SECONDS = 300
+_sidecar_version_cache = {}
+
+POLL_HARD_CAP_SECONDS = 12 * 3600
+POLL_UNREACHABLE_LIMIT_SECONDS = 600
+POLL_INTERVAL_SECONDS = 2
+
+
+def _typed_routing_suffix(model_id):
+ """'floor' or 'nitro' when the model id ends with that OpenRouter shortcut, else None."""
+ for suffix in ('floor', 'nitro'):
+ if str(model_id or '').endswith(f':{suffix}'):
+ return suffix
+ return None
+
+
+def reset_sidecar_version_cache():
+ _sidecar_version_cache.clear()
+
+
+def _parse_version(text):
+ """'1.3.4', '1.3.4-rc1' or 'v1.3.4' -> (1, 3, 4); None when it does not start with digits."""
+ numbers = re.match(r'v?(\d+)(?:\.(\d+))?(?:\.(\d+))?', str(text or ''))
+ if not numbers:
+ return None
+ return tuple(int(part or 0) for part in numbers.groups())
+
+
+def sidecar_version(base_url):
+ """The AI Subtitle Translator version behind ``base_url``, cached per URL.
+
+ Returns a version tuple, or None when the health endpoint is unreachable or
+ does not report a version. The probe is cheap and unauthenticated, and it is
+ cached so a job of many batches asks once.
+ """
+ base_url = (base_url or '').rstrip('/')
+ cached = _sidecar_version_cache.get(base_url)
+ if cached and cached[0] > time.monotonic():
+ return cached[1]
+ version = None
+ try:
+ response = requests.get(f"{base_url}/health", timeout=5)
+ if response.status_code == 200:
+ version = _parse_version(response.json().get('version'))
+ except (requests.exceptions.RequestException, ValueError, AttributeError) as e:
+ logger.debug("Could not read the AI Subtitle Translator version from %s: %s", base_url, e)
+ _sidecar_version_cache[base_url] = (time.monotonic() + SIDECAR_VERSION_CACHE_SECONDS, version)
+ return version
+
+
+def build_provider_config():
+ """The OpenRouter provider routing the sidecar applies to every request of a job.
+
+ Left unset the sidecar sorts providers by throughput, which is the fastest and
+ often not the cheapest endpoint; the setting lets the user pick price, latency,
+ the ``:nitro``/``:floor`` shortcuts, or OpenRouter's own load balancing. A
+ sidecar older than 1.3.4 (or one whose version cannot be read) does not know
+ the shortcuts and would hand them to OpenRouter as an invalid sort, so it gets
+ the plain sort each of them stands for.
+ """
+ routing = getattr(settings.translator, 'openrouter_provider_routing', DEFAULT_PROVIDER_ROUTING)
+ if routing not in PROVIDER_ROUTING_VALUES:
+ logger.warning("Unknown OpenRouter provider routing '%s', using %s", routing, DEFAULT_PROVIDER_ROUTING)
+ routing = DEFAULT_PROVIDER_ROUTING
+ typed = _typed_routing_suffix(getattr(settings.translator, 'openrouter_model', ''))
+ if typed:
+ # The slug already says how to route. A sidecar from 1.3.4 on drops the sort
+ # for a typed shortcut anyway; an older one forwards both, so the sort has to
+ # agree with the slug rather than with the setting.
+ return {'sort': ROUTING_PLAIN_SORT[typed]}
+ if routing in ROUTING_PLAIN_SORT:
+ version = sidecar_version(settings.translator.openrouter_url)
+ if version is None or version < ROUTING_SHORTCUTS_MIN_SIDECAR:
+ plain = ROUTING_PLAIN_SORT[routing]
+ logger.warning(
+ "AI Subtitle Translator %s does not support the '%s' provider routing (needs 1.3.4), sending %s",
+ '.'.join(map(str, version)) if version else 'of unknown version', routing, plain)
+ routing = plain
+ return {'sort': routing}
+
class OpenRouterTranslatorService:
"""
@@ -30,7 +118,7 @@ class OpenRouterTranslatorService:
def __init__(self, source_srt_file, dest_srt_file, lang_obj, to_lang, from_lang, media_type,
video_path, orig_to_lang, forced, hi, sonarr_series_id, sonarr_episode_id,
- radarr_id):
+ radarr_id, arr_instance_id=None):
self.source_srt_file = source_srt_file
self.dest_srt_file = dest_srt_file
self.lang_obj = lang_obj
@@ -44,6 +132,10 @@ def __init__(self, source_srt_file, dest_srt_file, lang_obj, to_lang, from_lang,
self.sonarr_series_id = sonarr_series_id
self.sonarr_episode_id = sonarr_episode_id
self.radarr_id = radarr_id
+ # The owning arr instance (#156): radarrId and sonarrSeriesId are only
+ # unique together with it, so every media lookup below carries it.
+ self.arr_instance_id = arr_instance_id
+ self.partial_error = None
self.language_code_convert_dict = {
'he': 'iw',
'zh': 'zh-CN',
@@ -78,45 +170,53 @@ def _get_api_key_value(self):
return api_key
def translate(self, job_id=None):
+ self.partial_error = None
try:
- subs = pysubs2.load(self.source_srt_file, encoding='utf-8')
- lines_list: List[str] = [x.plaintext for x in subs]
- lines_list_len = len(lines_list)
-
- if lines_list_len == 0:
- logger.debug('No lines to translate in subtitle file')
- return self.dest_srt_file
-
- logger.debug(f'Starting AI translation for {self.source_srt_file}') # noqa: G004
-
- # Submit job and poll for completion
- translated_lines = self._submit_and_poll(lines_list, bazarr_job_id=job_id)
-
- if translated_lines is None:
- logger.error(f'Translation failed for {self.source_srt_file}') # noqa: G004
- show_message(f'Translation failed for {self.source_srt_file}')
- return False
-
- # Process results
- logger.debug(f'BAZARR saving AI translated subtitles to {self.dest_srt_file}') # noqa: G004
- translation_map = {}
- for item in translated_lines:
- if isinstance(item, dict) and 'position' in item and 'line' in item:
- translation_map[item['position']] = item['line']
-
- for i, line in enumerate(subs):
- if i in translation_map and translation_map[i]:
- line.text = translation_map[i]
-
- try:
- subs.save(self.dest_srt_file)
- add_translator_info(self.dest_srt_file, "# Subtitles translated with AI Subtitle Translator #")
- except OSError:
- logger.error(f'BAZARR is unable to save translated subtitles to {self.dest_srt_file}') # noqa: G004
- show_message(f'Translation failed: Unable to save translated subtitles to {self.dest_srt_file}')
- raise OSError
-
- message = f"{language_from_alpha2(self.from_lang)} subtitles translated to {language_from_alpha3(self.to_lang)} using AI Subtitle Translator."
+ with staged_subtitle_write(self.video_path, self.dest_srt_file,
+ source_paths=(self.source_srt_file,),
+ before_publish=lambda: jobs_queue.update_job_progress(job_id=job_id),
+ allow_empty=True) as temporary:
+ subs = pysubs2.load(self.source_srt_file, encoding='utf-8')
+ lines_list: List[str] = [x.plaintext for x in subs]
+ lines_list_len = len(lines_list)
+
+ if lines_list_len == 0:
+ logger.debug('No lines to translate in subtitle file')
+ return False
+
+ logger.debug(f'Starting AI translation for {self.source_srt_file}') # noqa: G004
+
+ # Submit job and poll for completion
+ translated_lines = self._submit_and_poll(lines_list, bazarr_job_id=job_id)
+
+ if translated_lines is None:
+ logger.error(f'Translation failed for {self.source_srt_file}') # noqa: G004
+ show_message(f'Translation failed for {self.source_srt_file}')
+ return False
+
+ # Process results
+ logger.debug(f'BAZARR saving AI translated subtitles to {self.dest_srt_file}') # noqa: G004
+ translation_map = {}
+ for item in translated_lines:
+ if isinstance(item, dict) and 'position' in item and 'line' in item:
+ translation_map[item['position']] = item['line']
+
+ missing_lines = sum(bool(source.strip()) and not translation_map.get(i, '').strip()
+ for i, source in enumerate(lines_list))
+ if missing_lines and not self.partial_error:
+ self._mark_partial(f'No translated text was returned for {missing_lines} of {lines_list_len} cues.')
+
+ for i, line in enumerate(subs):
+ if i in translation_map and translation_map[i].strip():
+ line.text = translation_map[i]
+
+ subs.save(temporary)
+ translated = 'partially translated' if self.partial_error else 'translated'
+ add_translator_info(temporary, f"# Subtitles {translated} with AI Subtitle Translator #")
+
+ message = f"{language_from_alpha2(self.from_lang)} subtitles {translated} to {language_from_alpha3(self.to_lang)} using AI Subtitle Translator."
+ if self.partial_error:
+ message += f' Some lines may remain in the source language. {self.partial_error}'
result = create_process_result(message, self.video_path, self.orig_to_lang, self.forced, self.hi, self.dest_srt_file, self.media_type)
if self.media_type == 'episode':
@@ -131,6 +231,8 @@ def translate(self, job_id=None):
return self.dest_srt_file
+ except (JobCancelled, SubtitleDestinationChanged):
+ raise
except Exception as e:
logger.error(f'BAZARR encountered an error during AI translation: {str(e)}') # noqa: G004
show_message(f'AI translation failed: {str(e)}')
@@ -168,7 +270,8 @@ def _submit_and_poll(self, lines_list: List[str], bazarr_job_id=None) -> Optiona
media_type=self.media_type,
radarr_id=self.radarr_id,
sonarr_series_id=self.sonarr_series_id,
- sonarr_episode_id=self.sonarr_episode_id
+ sonarr_episode_id=self.sonarr_episode_id,
+ arr_instance_id=self.arr_instance_id
)
api_media_type = "Episode" if self.media_type == 'episode' else "Movie"
@@ -189,6 +292,7 @@ def _submit_and_poll(self, lines_list: List[str], bazarr_job_id=None) -> Optiona
"maxConcurrentJobs": settings.translator.openrouter_max_concurrent,
"parallelBatches": settings.translator.openrouter_parallel_batches,
"reasoning": self._build_reasoning_config(),
+ "provider": build_provider_config(),
}
}
@@ -229,13 +333,38 @@ def _submit_and_poll(self, lines_list: List[str], bazarr_job_id=None) -> Optiona
logger.error(f'AI Subtitle Translator error: {str(e)}') # noqa: G004
return None
+ def _mark_partial(self, detail):
+ self.partial_error = ' '.join(str(detail).split())[:500] or 'Some translation batches failed.'
+ logger.warning("Translation partially completed: %s", self.partial_error)
+ show_message('Translation is partial. Some lines may remain in the source language. '
+ f'{self.partial_error}')
+
def _poll_job(self, base_url: str, job_id: str, total_lines: int, bazarr_job_id=None) -> Optional[Any]:
- """Poll job status until completion"""
- poll_interval = 2 # seconds
- max_wait_time = 1800 # 30 minutes
- elapsed = 0
+ """Poll until a terminal status, subject to reachability and safety limits.
+
+ The sidecar owns request timeouts and retries, so there is no normal total-time cap.
+ A slow model with reasoning enabled and a shrunk batch size can take over half an hour.
+ The old 30-minute cap discarded a translation that the sidecar finished successfully.
+ A 12-hour hard cap remains as a safety net.
+ """
+ self.partial_error = None
+ started_at = time.monotonic()
+ last_reachable_at = started_at
+
+ while True:
+ now = time.monotonic()
+ if now - started_at >= POLL_HARD_CAP_SECONDS:
+ reason = "reached the 12-hour polling hard cap"
+ user_message = "Translation stopped after 12 hours"
+ break
+
+ unreachable_seconds = now - last_reachable_at
+ if unreachable_seconds >= POLL_UNREACHABLE_LIMIT_SECONDS:
+ unreachable_minutes = int(unreachable_seconds // 60)
+ reason = f"status endpoint unreachable for {unreachable_minutes} minutes"
+ user_message = f"Translation service unreachable for {unreachable_minutes} minutes"
+ break
- while elapsed < max_wait_time:
try:
status_response = requests.get(
f"{base_url}/api/v1/jobs/{job_id}",
@@ -245,10 +374,10 @@ def _poll_job(self, base_url: str, job_id: str, total_lines: int, bazarr_job_id=
if status_response.status_code != 200:
logger.error(f"Error getting job status: {status_response.status_code}") # noqa: G004
- time.sleep(poll_interval)
- elapsed += poll_interval
+ time.sleep(POLL_INTERVAL_SECONDS)
continue
+ last_reachable_at = time.monotonic()
job_status = status_response.json()
status = job_status.get("status")
progress = job_status.get("progress", 0)
@@ -275,15 +404,12 @@ def _poll_job(self, base_url: str, job_id: str, total_lines: int, bazarr_job_id=
if status == "completed":
hide_progress(id=f'translate_progress_{self.dest_srt_file}')
- result = job_status.get("result")
- if result:
- # Handle structured response with "lines" key from AI Subtitle Translator
- # The service returns {"lines": [...], "model_used": ..., "tokens_used": ...}
- if isinstance(result, dict) and "lines" in result:
- logger.debug(f'Extracted {len(result["lines"])} lines from structured result') # noqa: G004
- return result["lines"]
- # Fallback for direct list response
- return result
+ lines = self._validated_result_lines(job_status.get("result"), total_lines)
+ # An empty list is not a translation: saving it would write every source
+ # line under the target name and record a success in History.
+ if lines:
+ logger.debug(f'Extracted {len(lines)} lines from job result') # noqa: G004
+ return lines
logger.error("Job completed but no result returned")
return None
@@ -296,7 +422,11 @@ def _poll_job(self, base_url: str, job_id: str, total_lines: int, bazarr_job_id=
elif status == "partial":
hide_progress(id=f'translate_progress_{self.dest_srt_file}')
- error = job_status.get("error", message or "Partial translation")
+ error = job_status.get("error") or message or "Partial translation"
+ lines = self._validated_result_lines(job_status.get("result"), total_lines)
+ if lines is not None:
+ self._mark_partial(error)
+ return lines
logger.error(f"Translation partially failed: {error}") # noqa: G004
show_message(f"Translation failed (partial): {error}")
return None
@@ -307,20 +437,37 @@ def _poll_job(self, base_url: str, job_id: str, total_lines: int, bazarr_job_id=
return None
# Still processing or queued
- time.sleep(poll_interval)
- elapsed += poll_interval
+ time.sleep(POLL_INTERVAL_SECONDS)
except requests.exceptions.RequestException as e:
logger.warning(f"Error polling job status: {e}") # noqa: G004
- time.sleep(poll_interval)
- elapsed += poll_interval
+ time.sleep(POLL_INTERVAL_SECONDS)
- # Timeout
hide_progress(id=f'translate_progress_{self.dest_srt_file}')
- logger.error("Translation job timed out")
- show_message("Translation timed out after 30 minutes")
+ logger.error(f"Translation job {job_id} {reason}") # noqa: G004
+ show_message(user_message)
return None
+ @staticmethod
+ def _validated_result_lines(result, total_lines):
+ if isinstance(result, dict):
+ result = result.get('lines')
+ if not isinstance(result, list) or not result:
+ return None
+ positions = set()
+ has_translation = False
+ for item in result:
+ if not isinstance(item, dict):
+ return None
+ position = item.get('position')
+ line = item.get('line')
+ if (type(position) is not int or not 0 <= position < total_lines
+ or position in positions or not isinstance(line, str)):
+ return None
+ positions.add(position)
+ has_translation = has_translation or bool(line.strip())
+ return result if has_translation else None
+
@retry(exceptions=(TooManyRequests, RequestError, requests.exceptions.RequestException), tries=3, delay=1, backoff=2, jitter=(0, 1))
def _translate_sync(self, lines_list: List[str], payload: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
"""Fallback synchronous translation (Lingarr-compatible)"""
@@ -334,20 +481,11 @@ def _translate_sync(self, lines_list: List[str], payload: Dict[str, Any]) -> Opt
)
if response.status_code == 200:
- translated_batch = response.json()
- if isinstance(translated_batch, list):
- for item in translated_batch:
- if not isinstance(item, dict) or 'position' not in item or 'line' not in item:
- logger.error(f'Invalid response format: {item}') # noqa: G004
- return None
- return translated_batch
- else:
- logger.error(f'Unexpected response format: {translated_batch}') # noqa: G004
- return None
+ return self._validated_result_lines(response.json(), len(lines_list))
elif response.status_code == 429:
raise TooManyRequests("Rate limit exceeded")
elif response.status_code >= 500:
raise RequestError(f"Server error: {response.status_code}")
else:
logger.error(f'API error: {response.status_code} - {response.text}') # noqa: G004
- return None
\ No newline at end of file
+ return None
diff --git a/bazarr/subtitles/upload.py b/bazarr/subtitles/upload.py
index 3cbd7f40ab..3e1bc0152d 100644
--- a/bazarr/subtitles/upload.py
+++ b/bazarr/subtitles/upload.py
@@ -4,6 +4,7 @@
import os
import sys
import logging
+from functools import partial
from subzero.language import Language
from subliminal_patch.core import save_subtitles
@@ -28,16 +29,59 @@
from app.event_handler import event_stream
from app.notifier import send_notifications
from app.notifier import send_notifications_movie
-from subtitles.indexer.series import store_subtitles
-from subtitles.indexer.movies import store_subtitles_movie
from subtitles.processing import ProcessSubtitlesResult
+from subtitles.tools.subsync_engines import (SubtitlePublication, write_subtitle_file,
+ subtitle_source_version, subtitle_write_locks)
-from .sync import sync_subtitles
+from .sync import sync_subtitles, _index_keep_all_outputs
from .post_processing import postprocessing
from plex.operations import plex_set_movie_added_date_now, plex_set_episode_added_date_now, plex_refresh_item
from jellyfin.operations import jellyfin_refresh_item
+def _refresh_uploaded_subtitles(video_path, subtitle_path, sonarr_series_id=None, sonarr_episode_id=None,
+ radarr_id=None, arr_instance_id=None):
+ _index_keep_all_outputs(video_path, sonarr_series_id=sonarr_series_id,
+ sonarr_episode_id=sonarr_episode_id, radarr_id=radarr_id,
+ arr_instance_id=arr_instance_id)
+
+
+def _notify_upload(consumer, callback, *args, **kwargs):
+ try:
+ callback(*args, **kwargs)
+ except Exception as exc:
+ logging.warning('BAZARR upload notification failed for %s (%s)', consumer, type(exc).__name__)
+
+
+def _refresh_upload_consumers(media_type, metadata, arr_instance_id):
+ callbacks = []
+ if media_type == 'series':
+ callbacks.append(('Sonarr', lambda: notify_sonarr(
+ metadata.sonarrSeriesId,
+ arr_client=client_for_instance(database, arr_instance_id, enabled_only=False))))
+ if settings.general.use_plex and settings.plex.update_series_library:
+ callbacks.append(('Plex', lambda: plex_refresh_item(
+ metadata.imdbId, is_movie=False, season=metadata.season, episode=metadata.episode)))
+ if settings.general.use_jellyfin and settings.jellyfin.update_series_library:
+ callbacks.append(('Jellyfin', lambda: jellyfin_refresh_item(
+ metadata.imdbId, is_movie=False, season=metadata.season, episode=metadata.episode,
+ tvdb_id=metadata.tvdbId)))
+ else:
+ callbacks.append(('Radarr', lambda: notify_radarr(
+ metadata.radarrId,
+ arr_client=client_for_instance(database, arr_instance_id, enabled_only=False))))
+ if settings.general.use_plex and settings.plex.update_movie_library:
+ callbacks.append(('Plex', lambda: plex_refresh_item(metadata.imdbId, is_movie=True)))
+ if settings.general.use_jellyfin and settings.jellyfin.update_movie_library:
+ callbacks.append(('Jellyfin', lambda: jellyfin_refresh_item(
+ metadata.imdbId, is_movie=True, tmdb_id=metadata.tmdbId)))
+ for consumer, callback in callbacks:
+ try:
+ callback()
+ except Exception as exc:
+ logging.warning('BAZARR upload refresh failed for %s (%s)', consumer, type(exc).__name__)
+
+
def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, filename, audio_language, job_id=None,
sonarrSeriesId=None, sonarrEpisodeId=None, radarrId=None, arr_instance_id=None):
if not job_id:
@@ -87,6 +131,8 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, fil
.first()
if episode_metadata:
+ sonarrSeriesId = episode_metadata.sonarrSeriesId
+ sonarrEpisodeId = episode_metadata.sonarrEpisodeId
use_original_format = bool(get_profiles_list(episode_metadata.profileId)["originalFormat"])
else:
return
@@ -99,6 +145,7 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, fil
.first()
if movie_metadata:
+ radarrId = movie_metadata.radarrId
use_original_format = bool(get_profiles_list(movie_metadata.profileId)["originalFormat"])
else:
return
@@ -133,14 +180,22 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, fil
try:
# ensure that formats must be a tuple of strings
sub_format = (sub.format,) if isinstance(sub.format, str) else sub.format
- saved_subtitles = save_subtitles(path,
- [sub],
- single=single,
- tags=None, # fixme
- directory=get_target_folder(path),
- chmod=chmod,
- formats=sub_format if use_original_format else ("srt",),
- path_decoder=force_unicode)
+ subtitle_directory = get_target_folder(path)
+ with subtitle_write_locks(path, os.path.join(subtitle_directory or os.path.dirname(path), '.destination')):
+ written_paths = []
+ saved_subtitles = save_subtitles(path,
+ [sub],
+ single=single,
+ tags=None, # fixme
+ directory=subtitle_directory,
+ chmod=chmod,
+ formats=sub_format if use_original_format else ("srt",),
+ path_decoder=force_unicode,
+ write_subtitle=partial(write_subtitle_file, path, written_paths=written_paths))
+ saved_subtitles = [saved for saved in saved_subtitles if saved.storage_path in written_paths]
+ source_version = subtitle_source_version(saved_subtitles[0].storage_path) if saved_subtitles else None
+ source_publication = (SubtitlePublication(path, saved_subtitles[0].storage_path, source_version)
+ if source_version is not None else None)
except Exception as e:
logging.exception(f'BAZARR Error saving Subtitles file to disk for this file {path}: {repr(e)}') # noqa: G004
return
@@ -185,14 +240,20 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, fil
uploaded_language_code3, audio_language['name'], audio_language['code2'],
audio_language['code3'], 100, "1", "manual", "user", "unknown", sonarrSeriesId,
sonarrEpisodeId or radarrId,)
- postprocessing(command, path)
- set_chmod(subtitles_path=subtitle_path)
+ with subtitle_write_locks(path, subtitle_path):
+ if subtitle_source_version(subtitle_path) == source_version:
+ postprocessing(command, path, subtitle_path=subtitle_path)
+ set_chmod(subtitles_path=subtitle_path)
+ source_version = subtitle_source_version(subtitle_path)
+ source_publication.release()
+ source_publication = SubtitlePublication(path, subtitle_path, source_version)
+
+ refresh_subtitles = partial(_refresh_uploaded_subtitles, path, subtitle_path, sonarr_series_id=sonarrSeriesId,
+ sonarr_episode_id=sonarrEpisodeId, radarr_id=radarrId,
+ arr_instance_id=arr_instance_id)
+ refresh_subtitles()
if media_type == 'series':
- sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, percent_score=100,
- sonarr_series_id=episode_metadata.sonarrSeriesId, forced=forced, hi=hi,
- sonarr_episode_id=episode_metadata.sonarrEpisodeId, job_id=job_id,
- arr_instance_id=arr_instance_id)
# Reverse-map through the owning instance's path_mappings (#156); None
# owner => global mapping (the default/single-instance path), unchanged.
reversed_path = path_mappings.path_replace_reverse_instance(path, arr_instance_id, "series")
@@ -200,21 +261,14 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, fil
subtitle_path, arr_instance_id, "series")
# Route the rescan at the OWNING instance's server (#156). None owner =
# default server (legacy single-instance), unchanged.
- notify_sonarr(episode_metadata.sonarrSeriesId,
- arr_client=client_for_instance(database, arr_instance_id, enabled_only=False))
event_stream(type='series', action='update', payload=episode_metadata.sonarrSeriesId)
event_stream(type='episode-wanted', action='delete', payload=episode_metadata.sonarrEpisodeId)
else:
- sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, percent_score=100,
- radarr_id=movie_metadata.radarrId, forced=forced, hi=hi, job_id=job_id,
- arr_instance_id=arr_instance_id)
# Reverse-map through the owning instance's path_mappings (#156); None
# owner => global mapping (the default/single-instance path), unchanged.
reversed_path = path_mappings.path_replace_reverse_instance(path, arr_instance_id, "movie")
reversed_subtitles_path = path_mappings.path_replace_reverse_instance(
subtitle_path, arr_instance_id, "movie")
- notify_radarr(movie_metadata.radarrId,
- arr_client=client_for_instance(database, arr_instance_id, enabled_only=False))
event_stream(type='movie', action='update', payload=movie_metadata.radarrId)
event_stream(type='movie-wanted', action='delete', payload=movie_metadata.radarrId)
@@ -240,32 +294,27 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, fil
history_log(4, sonarrSeriesId, sonarrEpisodeId, result, fake_provider=provider,
fake_score=MAX_SCORES['episode'], arr_instance_id=arr_instance_id)
if not settings.general.dont_notify_manual_actions:
- send_notifications(sonarrSeriesId, sonarrEpisodeId, result.message,
+ _notify_upload("user", send_notifications, sonarrSeriesId, sonarrEpisodeId, result.message,
arr_instance_id=arr_instance_id)
- store_subtitles(result.path, path, arr_instance_id=arr_instance_id)
if settings.general.use_plex:
- if settings.plex.update_series_library:
- plex_refresh_item(episode_metadata.imdbId, is_movie=False,
- season=episode_metadata.season, episode=episode_metadata.episode)
if settings.plex.set_episode_added:
- plex_set_episode_added_date_now(episode_metadata)
- if settings.general.use_jellyfin and settings.jellyfin.update_series_library:
- jellyfin_refresh_item(episode_metadata.imdbId, is_movie=False,
- season=episode_metadata.season, episode=episode_metadata.episode,
- tvdb_id=episode_metadata.tvdbId)
+ _notify_upload("Plex added date", plex_set_episode_added_date_now, episode_metadata)
else:
history_log_movie(4, radarrId, result, fake_provider=provider, fake_score=MAX_SCORES['movie'],
arr_instance_id=arr_instance_id)
if not settings.general.dont_notify_manual_actions:
- send_notifications_movie(radarrId, result.message, arr_instance_id=arr_instance_id)
- store_subtitles_movie(result.path, path, arr_instance_id=arr_instance_id)
+ _notify_upload("user", send_notifications_movie, radarrId, result.message, arr_instance_id=arr_instance_id)
if settings.general.use_plex:
- if settings.plex.update_movie_library:
- plex_refresh_item(movie_metadata.imdbId, is_movie=True)
if settings.plex.set_movie_added:
- plex_set_movie_added_date_now(movie_metadata)
- if settings.general.use_jellyfin and settings.jellyfin.update_movie_library:
- jellyfin_refresh_item(movie_metadata.imdbId, is_movie=True,
- tmdb_id=movie_metadata.tmdbId)
-
+ _notify_upload("Plex added date", plex_set_movie_added_date_now, movie_metadata)
+
+ refresh_consumers = partial(_refresh_upload_consumers, media_type,
+ episode_metadata if media_type == 'series' else movie_metadata, arr_instance_id)
+ refresh_consumers()
+ if source_publication is not None:
+ sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2,
+ percent_score=100, forced=forced, hi=hi, sonarr_series_id=sonarrSeriesId,
+ sonarr_episode_id=sonarrEpisodeId, radarr_id=radarrId,
+ arr_instance_id=arr_instance_id, callback=refresh_subtitles,
+ source_version=source_publication, on_success=refresh_consumers)
return '', 204
diff --git a/custom_libs/subliminal_patch/core.py b/custom_libs/subliminal_patch/core.py
index 3829e6b199..c24757daa6 100644
--- a/custom_libs/subliminal_patch/core.py
+++ b/custom_libs/subliminal_patch/core.py
@@ -23,7 +23,7 @@
from concurrent.futures import as_completed
from .extensions import provider_registry
-from .exceptions import APIThrottled, MustGetBlacklisted
+from .exceptions import APIThrottled, MustGetBlacklisted, SubtitleCandidateRejected
from .score import compute_score, MAX_SCORES
from subliminal.video import VIDEO_EXTENSIONS, Video, Episode, Movie
from subliminal.core import guessit, ProviderPool, ThreadPoolExecutor, check_video
@@ -709,6 +709,10 @@ def download_subtitle(self, subtitle):
self.post_download_hook(subtitle)
break
+ except SubtitleCandidateRejected as e:
+ logger.warning('Subtitle candidate rejected: %s', e)
+ return False
+
except (requests.ConnectionError,
requests.exceptions.ProxyError,
requests.exceptions.SSLError,
@@ -871,10 +875,10 @@ def download_best_subtitles(self, subtitles, video, languages, min_score=0, hear
subtitle.score = score
downloaded_subtitles.append(subtitle)
- # stop if only one subtitle is requested
- if only_one:
- logger.debug('Only one subtitle downloaded')
- break
+ # stop after one successful download, not one attempted candidate
+ if only_one:
+ logger.debug('Only one subtitle downloaded')
+ break
# --- WHISPER FALLBACK PRECONDITIONS ---
# 1. No regular provider results with at least minimum score
@@ -1467,7 +1471,7 @@ def get_subtitle_path(video_path, language=None, extension='.srt', forced_tag=Fa
def save_subtitles(file_path, subtitles, single=False, directory=None, chmod=None, formats=("srt",),
- tags=None, path_decoder=None, debug_mods=False):
+ tags=None, path_decoder=None, debug_mods=False, write_subtitle=None):
"""Save subtitles on filesystem.
Subtitles are saved in the order of the list. If a subtitle with a language has already been saved, other subtitles
@@ -1482,6 +1486,7 @@ def save_subtitles(file_path, subtitles, single=False, directory=None, chmod=Non
:type subtitles: list of :class:`~subliminal.subtitle.Subtitle`
:param bool single: save a single subtitle, default is to save one subtitle per language.
:param str directory: path to directory where to save the subtitles, default is next to the video.
+ :param write_subtitle: optional callback receiving each exact destination and encoded content
:return: the saved subtitles
:rtype: list of :class:`~subliminal.subtitle.Subtitle`
@@ -1539,11 +1544,13 @@ def save_subtitles(file_path, subtitles, single=False, directory=None, chmod=Non
logger.debug(u"Saving %r to %r", subtitle, subtitle_path)
content = subtitle.get_modified_content(format=format, debug=debug_mods)
if content:
- if os.path.exists(subtitle_path):
- os.remove(subtitle_path)
-
- with open(subtitle_path, 'wb') as f:
- f.write(content)
+ if write_subtitle is not None:
+ write_subtitle(subtitle_path, content)
+ else:
+ if os.path.exists(subtitle_path):
+ os.remove(subtitle_path)
+ with open(subtitle_path, 'wb') as f:
+ f.write(content)
subtitle.storage_path = subtitle_path
else:
logger.error(u"Something went wrong when getting modified subtitle for %s", subtitle)
diff --git a/custom_libs/subliminal_patch/exceptions.py b/custom_libs/subliminal_patch/exceptions.py
index 0714f23080..8567114a10 100644
--- a/custom_libs/subliminal_patch/exceptions.py
+++ b/custom_libs/subliminal_patch/exceptions.py
@@ -15,6 +15,13 @@ def __init__(self, *args, retry_after=None, **kwargs):
self.retry_after = retry_after
+class SubtitleCandidateRejected(ProviderError):
+ """A valid response has no usable subtitle for this candidate.
+
+ Messages must contain only bounded, non-sensitive diagnostic context.
+ """
+
+
class ForbiddenError(ProviderError):
pass
diff --git a/custom_libs/subliminal_patch/providers/utils.py b/custom_libs/subliminal_patch/providers/utils.py
index 4857e818c0..136d3e8789 100644
--- a/custom_libs/subliminal_patch/providers/utils.py
+++ b/custom_libs/subliminal_patch/providers/utils.py
@@ -113,51 +113,69 @@ def wrapper(self, subtitle):
return decorator
+def _contains_number(guessed, requested):
+ guessed_numbers = guessed if isinstance(guessed, (list, tuple)) else (guessed,)
+ requested_numbers = requested if isinstance(requested, (list, tuple)) else (requested,)
+ return any(number in guessed_numbers for number in requested_numbers)
+
+
def _get_matching_sub(
- sub_names, forced=False, episode=None, episode_title=None, **kwargs
+ sub_names, forced=False, episode=None, episode_title=None, log_member_names=True,
+ match_episode_context=False, season=None, **kwargs
):
- guess_options = {"single_value": True}
- if episode is not None:
+ guess_options = {} if match_episode_context else {"single_value": True}
+ if episode is not None or (match_episode_context and season is not None):
guess_options["type"] = "episode" # type: ignore
matching_subs = []
+ movie_selection = episode is None and episode_title is None
for sub_name in sub_names:
if not forced and os.path.splitext(sub_name.lower())[0].endswith("forced"):
- logger.debug("Ignoring forced subtitle: %s", sub_name)
+ if log_member_names:
+ logger.debug("Ignoring forced subtitle: %s", sub_name)
+ continue
+
+ guess = {}
+ if not movie_selection or (match_episode_context and season is not None):
+ guess = guessit(sub_name, options=guess_options)
+ if (match_episode_context and season is not None and guess.get("season") is not None
+ and not _contains_number(guess["season"], season)):
continue
- # If it's a movie then get the first subtitle
- if episode is None and episode_title is None:
- logger.debug("Movie subtitle found: %s", sub_name)
+ # Without episode context, get the first member that passed the season check.
+ if movie_selection:
+ if log_member_names:
+ logger.debug("Movie subtitle found: %s", sub_name)
matching_subs.append(_MatchingSub(sub_name, 2, "Movie subtitle"))
break
- guess = guessit(sub_name, options=guess_options)
-
matched_episode_num = guess.get("episode")
- if not matched_episode_num:
+ if not matched_episode_num and log_member_names:
logger.debug("No episode number found in file: %s", sub_name)
if episode_title is not None:
- from_name = _analize_sub_name(sub_name, episode_title)
+ from_name = _analize_sub_name(sub_name, episode_title, log_member_names)
if from_name is not None:
matching_subs.append(from_name)
- if episode == matched_episode_num:
- logger.debug("Episode matched from number: %s", sub_name)
+ if (_contains_number(matched_episode_num, episode) if match_episode_context
+ else episode == matched_episode_num):
+ if log_member_names:
+ logger.debug("Episode matched from number: %s", sub_name)
matching_subs.append(_MatchingSub(sub_name, 2, "Episode number matched"))
if matching_subs:
matching_subs.sort(key=lambda x: x.priority, reverse=True)
- logger.debug("Matches: %s", matching_subs)
+ if log_member_names:
+ logger.debug("Matches: %s", matching_subs)
return matching_subs[0].file
else:
logger.debug("Nothing matched")
return None
-def _analize_sub_name(sub_name: str, title_: str):
+def _analize_sub_name(sub_name: str, title_: str, log_member_names=True):
titles = re.split(r"[\s_\.\+]?[.-][\s_\.\+]?", os.path.splitext(sub_name)[0])
for title in titles:
@@ -165,9 +183,10 @@ def _analize_sub_name(sub_name: str, title_: str):
ratio = SequenceMatcher(None, title.lower(), title_.lower()).ratio()
if ratio > 0.85:
- logger.debug(
- "Episode title matched: '%s' -> '%s' [%s]", title, sub_name, ratio
- )
+ if log_member_names:
+ logger.debug(
+ "Episode title matched: '%s' -> '%s' [%s]", title, sub_name, ratio
+ )
# Avoid false positives with short titles
if len(title_) > 4 and ratio >= 0.98:
@@ -175,7 +194,8 @@ def _analize_sub_name(sub_name: str, title_: str):
return _MatchingSub(sub_name, 1, "Normal title ratio")
- logger.debug("No episode title matched from file: %s", sub_name)
+ if log_member_names:
+ logger.debug("No episode title matched from file: %s", sub_name)
return None
@@ -185,34 +205,57 @@ def get_subtitle_from_archive(
episode=None,
get_first_subtitle=False,
extensions=DEFAULT_ARCHIVE_EXTENSIONS,
+ match_episode_context=False,
+ log_member_names=True,
+ season=None,
**kwargs,
):
- "Get subtitle from Rarfile/Zipfile object. Return None if nothing is found."
+ """Return subtitle bytes, or None if no member matches.
+
+ The host can match all included episodes and reject explicit season mismatches
+ while supplying sanitized diagnostics. Defaults preserve provider behavior.
+ An explicit first-subtitle request remains authoritative.
+ """
subs_in_archive = list_subtitle_members(archive, extensions)
if not subs_in_archive:
# Name the members: without them a user whose download failed here has no
# way to tell an archive full of the wrong extensions from a broken one.
- logger.warning(
- "No subtitles found in archive. Members: %s", _archive_member_names(archive)
- )
+ if log_member_names:
+ logger.warning(
+ "No subtitles found in archive. Members: %s", _archive_member_names(archive)
+ )
return None
- logger.debug("Subtitles in archive: %s", subs_in_archive)
+ if log_member_names:
+ logger.debug("Subtitles in archive: %s", subs_in_archive)
if len(subs_in_archive) == 1 or get_first_subtitle:
- logger.debug("Getting first subtitle in archive: %s", subs_in_archive)
+ if match_episode_context and (episode is not None or season is not None) and not get_first_subtitle:
+ guess = guessit(
+ subs_in_archive[0], options={"type": "episode"}
+ )
+ for key, requested in (("season", season), ("episode", episode)):
+ if requested is not None and guess.get(key) is not None and not _contains_number(guess[key], requested):
+ return None
+ if log_member_names:
+ logger.debug("Getting first subtitle in archive: %s", subs_in_archive)
return fix_line_ending(archive.read(subs_in_archive[0]))
- matching_sub = _get_matching_sub(subs_in_archive, forced, episode, **kwargs)
+ matching_sub = _get_matching_sub(
+ subs_in_archive, forced, episode, log_member_names=log_member_names,
+ match_episode_context=match_episode_context, season=season, **kwargs
+ )
if matching_sub is not None:
- logger.info("Using %s from archive", matching_sub)
+ if log_member_names:
+ logger.info("Using %s from archive", matching_sub)
return fix_line_ending(archive.read(matching_sub))
- logger.warning(
- "No subtitle in archive matched this episode. Members: %s", subs_in_archive
- )
+ if log_member_names:
+ logger.warning(
+ "No subtitle in archive matched this episode. Members: %s", subs_in_archive
+ )
return None
diff --git a/docs/release-notes/v2.6.2-clockwork.md b/docs/release-notes/v2.6.2-clockwork.md
new file mode 100644
index 0000000000..8efc5e3a9c
--- /dev/null
+++ b/docs/release-notes/v2.6.2-clockwork.md
@@ -0,0 +1,141 @@
+# Bazarr+ v2.6.2 (Clockwork)
+
+Codename: **Clockwork**
+
+A stability patch for subtitle uploads, searches, disk scans and translation. Uploaded subtitles appear before automatic sync finishes. Downloads keep their server ownership, sync outputs stay with the correct video, and useful partial OpenRouter translations are saved with an honest incomplete status. OpenRouter routing choices now stay consistent with the model field, and Provider Hub distinguishes a bad subtitle candidate from a provider-wide failure.
+
+---
+
+## Headline: Keep useful subtitle work and continue past unusable candidates
+
+### Partial translations are recoverable
+
+The OpenRouter translator service can return useful lines even when some batches fail. Bazarr previously discarded that partial result. It now saves valid translated lines, retains the source text where translation is missing, and marks the result as partially translated in history, job status and the subtitle footer. An empty or malformed result still fails. Saving uses a temporary file, so an unsuccessful write or footer update preserves any existing subtitle.
+
+The host also waits for long-running OpenRouter translation jobs while the service remains reachable, with a 12-hour overall limit and a 10-minute limit without a successful status response. This does not repair a translator service's own model or response-parsing errors.
+
+The separately deployed translator service reference advances to v1.3.4. The included service updates tolerate more malformed model replies, let batch sizes recover after failures, and report how many lines were actually translated. They align the prompt with JSON object mode, retry incomplete replies at the minimum batch size, and account for usage from failed or retried batches. Version 1.3.4 adds provider routing. Updating Bazarr alone does not replace a running translator service.
+
+### Uploads stay visible while sync runs
+
+A successful subtitle upload previously waited for automatic synchronization before appearing in the media's subtitle list. It now appears as soon as the upload is finalized, and synchronization runs as a separate progress job. Its completion refreshes the list, including saved outputs from multiple engines. A failed, skipped or cancelled sync leaves the uploaded subtitle available.
+
+Queued and running sync work also checks whether its source was replaced or deleted. An older sync result cannot overwrite a newer upload or restore a subtitle the user removed. Existing synchronization settings, instance ownership and upload history are retained.
+
+If older synchronized outputs cannot be moved aside after a new subtitle is saved, Bazarr reports the cleanup failure separately and keeps the completed save available. Those older outputs may remain listed until cleanup succeeds.
+
+### OpenRouter routing matches the saved settings
+
+Settings now offer fastest, cheapest, lowest-latency and OpenRouter-default routing, with separate `:nitro` and `:floor` shortcut choices. Typing either shortcut into the model field moves it into the routing selector when editing finishes and keeps the plain model ID for model details. Stacked routing shortcuts adopt the last one; other model variants remain unchanged. Lookalike text such as `:floorplan` is not rewritten.
+
+When the settings Save bar is visible, Enter, Ctrl+S and Cmd+S now include the focused field's latest edit before saving, including fields that apply changes when focus leaves them. Previously a keyboard save could retain the earlier value even though the field showed the new text. Ctrl+S and Cmd+S still require a pending settings change before starting a save.
+
+Full routing support requires translator service v1.3.4. Older or unrecognized service versions receive compatible plain routing choices where possible. Routing can affect provider selection and cost when a model has multiple providers; it does not guarantee a particular price or completion time.
+
+### An unusable archive does not disable the provider
+
+A valid catalog-provider archive can contain no subtitle for the requested episode or language. That now rejects the candidate and lets the search continue, including when only one successful subtitle is requested. Automatic archive selection handles filenames covering multiple episodes and relative or absolute episode numbers. It checks the requested season when filenames provide one. Selector callbacks are limited to the offered subtitle files. Corrupt archives, invalid selectors and unsafe archive paths still follow the provider-failure path.
+
+Provider workers also retain recognized authentication, quota, rate-limit and service errors. These reach Bazarr's matching pause policy instead of all becoming a generic worker error. OpenSubtitles.com download-quota errors use the existing six-hour pause; this change does not claim to know the account's exact quota-reset time.
+
+---
+
+## Other Improvements & Fixes
+
+- Dropping files into an open movie or series upload dialog no longer opens a second dialog. This also prevents extra dialogs while an archive is being expanded. Existing file selections are retained, and closing once dismisses the upload dialog.
+- Disk scans associate saved sync-engine outputs with their actual video. Case differences, Unicode normalization, language tags and sibling videos with dotted names are handled without duplicating or assigning another video's subtitles. Existing language-tagged files remain discoverable after single-language naming is enabled.
+- History preserves an explicitly supplied Sonarr or Radarr owner when a media lookup fails, avoiding the reported non-null ownership error.
+- Global search shows the owning instance name when multiple Sonarr instances or multiple Radarr instances are configured for that media kind.
+- Movie translation jobs now show the movie title, and Gemini receives its overview. Translations started from a media page use the owning Sonarr or Radarr instance for title and overview lookups, so matching upstream IDs on another server do not supply the wrong context.
+- Cancelling a Gemini translation no longer retries the request. Cancellation at the initial or final progress update also removes temporary progress files, while the existing destination subtitle is preserved.
+- Translator status cards remain visible when idle. Available counts show zero, and unavailable counts keep a placeholder while loading or reconnecting.
+- Automatic translation checks eligibility before logging that a source score is too low, including when automatic translation is disabled.
+- Translation and subtitle modifications retain an existing file's directory. New files use the configured subtitle folder, including relative and absolute custom folders.
+- Four retired OpenRouter model IDs are replaced in saved settings only when one of those known IDs is selected. New installations default to `google/gemini-2.5-flash-lite`; other custom selections are preserved. The model menu and translation guide are updated too. Grok menu suggestions are refreshed separately; saved Grok model IDs are not automatically migrated.
+- Startup dependency checks now agree with the versions selected in `requirements.txt`, preventing unnecessary repeated repair-and-restart attempts on otherwise compliant source installations.
+
+### Provider catalog updates
+
+Provider bundles have a separate release channel from the application. At preparation time, catalog `beta` includes OpenSubtitles.com 0.1.9 regional-language matching, anonymous OpenSubtitles.org 0.1.12 episode search and Anubis clearance, Titlovi 0.1.8 season-pack eligibility, and SubDL 0.1.4 error classification. Promotion to catalog `main` is still pending. Installing this application version alone does not replace those bundles. SubDL distinguishes documented exhausted-quota responses from other HTTP 429 rate limits and ordinary service failures.
+
+Anonymous OpenSubtitles.org and OpenSubtitles.com regional-language searches were verified through search, download-link creation and nonempty subtitle streams. Titlovi's season-pack correction passed local tests, but live download verification remains limited by the configured account's disabled API access. SubDL movie search and download passed; its episode control returned zero results with both the prior and updated versions, so episode downloads remain unverified. Current Subsource catalog searches and downloads passed; the reported failures came from the retired built-in implementation.
+
+---
+
+## CI / Docker
+
+The regression suites run in the standard Python 3.12, 3.13 and 3.14 matrix, including native PostgreSQL checks. Coverage includes upload visibility and synchronization races, settings submission, translation routing, provider error handling, ownership and runtime dependency checks. Release acceptance checks the combined production image, startup, the served UI and release-tour navigation, with provider canaries recorded separately. Frontend builds use Node 24.20.0 LTS, satisfying the installed dependencies' runtime requirements. Existing CI and Docker build workflows read this shared version pin. No Docker base-image change is included.
+
+---
+
+## Dependency Updates
+
+- Raise the cryptography security floor to 50.0.1 in both installation and runtime checks. Existing encrypted settings and ciphertext compatibility were verified.
+- Update the frontend's transitive Browserslist dependency from 4.28.4 to 4.28.7 with its required metadata packages.
+- Update GuessIt from 3.8.0 to 4.4.0 for subtitle filename parsing, and align its startup version check.
+- Update SQLAlchemy from 2.0.51 to 2.0.52, and align its startup version check.
+- Update Mantine from 9.5.2 to 9.6.0, including its react-dropzone 20 upload dependency.
+- Align the remaining stale runtime declarations with the dependencies already selected by the installation requirements. Version checks also distinguish prereleases from the required final and post-release versions.
+
+---
+
+## Database Migrations
+
+No schema changes. There are no new database migrations in this patch. Existing library and history data are preserved.
+
+---
+
+## Included Pull Requests
+
+- LavX/bazarr#407: Update GuessIt and its runtime version check.
+- LavX/bazarr#409: Update Mantine and verify upload compatibility.
+- LavX/bazarr#410: Update SQLAlchemy and its runtime version check.
+- LavX/bazarr#411: Preserve explicit history ownership when media lookup fails.
+- LavX/bazarr#412: Check automatic-translation eligibility before source-score logging.
+- LavX/bazarr#413: Show instance names in global search.
+- LavX/bazarr#414: Preserve Provider Hub semantic errors across the worker boundary.
+- LavX/bazarr#415: Scope sync-engine output discovery to the owning video.
+- LavX/bazarr#416: Preserve partial translations, wait for long jobs, and respect subtitle destinations.
+- LavX/bazarr#417: Update the Browserslist security dependency.
+- LavX/bazarr#418: Continue past unusable provider archive candidates.
+- LavX/bazarr#419: Align the cryptography security floor and verify encrypted-setting compatibility.
+- LavX/bazarr#420: Align startup dependency declarations and correct version comparisons.
+- LavX/bazarr#421: Update the translator service reference to v1.3.1.
+- LavX/bazarr#422: Update the translator service reference to v1.3.2.
+- LavX/bazarr#423: Update the translator service reference to v1.3.4.
+- LavX/bazarr#424: Keep nested subtitle drops in one upload dialog.
+- LavX/bazarr#425: Configure OpenRouter provider routing and compatibility with older services.
+- LavX/bazarr#426: Normalize routing shortcuts and commit edited settings before keyboard saves.
+- LavX/bazarr#427: Name movie translation jobs and scope media context to the owning server.
+- LavX/bazarr#428: Show uploaded subtitles immediately and synchronize them in a separate job.
+- LavX/bazarr#429: Protect subtitle changes during background sync, stop cancelled Gemini requests, keep translator status cards visible, and prepare this release.
+
+Catalog changes: [OpenSubtitles.com](https://github.com/LavX/bazarr-provider-catalog/pull/109), [Titlovi](https://github.com/LavX/bazarr-provider-catalog/pull/110), [SubDL](https://github.com/LavX/bazarr-provider-catalog/pull/111), and [OpenSubtitles.org](https://github.com/LavX/bazarr-provider-catalog/pull/112).
+
+---
+
+## Upgrade / Migration Notes
+
+- Update and restart normally. Back up your configuration and database before upgrading.
+- Review subtitles marked partially translated: some cues may remain in the source language. A saved partial result is not a complete translation.
+- Update the separately deployed translator service to v1.3.4 for the parsing, retry, usage-accounting and routing changes. Updating Bazarr alone does not replace a running translator service.
+- Review Provider Routing in Settings > AI Translator. Fastest remains the default; choosing Cheapest or `:floor` can select different providers. Existing model variants remain valid, and editing a trailing `:nitro` or `:floor` moves that choice into the routing selector.
+- If you used one of the retired OpenRouter model IDs, check the replacement in Settings > AI Translator. Other custom model IDs are retained.
+- Run Scan Disk to refresh existing sync-output entries after upgrading. Keep all engine outputs still intentionally saves one file per engine, and this fix does not delete files. Unrelated videos with identical basenames remain ambiguous in a shared absolute subtitle folder; use separate subtitle folders for those files.
+- Update provider bundles separately through Subtitle Hub when their catalog updates are available. OpenSubtitles.org's scraper supports anonymous downloads; OpenSubtitles.com and Titlovi have their own account requirements.
+- SQLite and PostgreSQL remain supported. Native Windows dependency execution was not part of this release's test-machine checks.
+
+---
+
+## Docker
+
+```bash
+docker pull ghcr.io/lavx/bazarr:2.6.2
+docker pull ghcr.io/lavx/bazarr:latest
+```
+
+After upgrading, confirm that the UI loads and `/api/system/status` reports `2.6.2`.
+
+---
+
+**Full Changelog:** https://github.com/LavX/bazarr/compare/v2.6.1...v2.6.2
diff --git a/frontend/.nvmrc b/frontend/.nvmrc
index 6fa8dec4cd..b7179aed06 100644
--- a/frontend/.nvmrc
+++ b/frontend/.nvmrc
@@ -1 +1 @@
-22.13.0
+24.20.0
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e74d488b8e..a8bf4b3ad9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.9",
- "@mantine/core": "^9.5.2",
- "@mantine/dropzone": "^9.5.2",
- "@mantine/form": "^9.5.2",
- "@mantine/hooks": "^9.5.2",
- "@mantine/modals": "^9.5.2",
- "@mantine/notifications": "^9.5.2",
+ "@mantine/core": "^9.6.0",
+ "@mantine/dropzone": "^9.6.0",
+ "@mantine/form": "^9.6.0",
+ "@mantine/hooks": "^9.6.0",
+ "@mantine/modals": "^9.6.0",
+ "@mantine/notifications": "^9.6.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.3",
@@ -2131,32 +2131,32 @@
}
},
"node_modules/@floating-ui/core": {
- "version": "1.7.5",
- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
- "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
+ "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
- "@floating-ui/utils": "^0.2.11"
+ "@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
- "version": "1.7.6",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
- "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
+ "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
- "@floating-ui/core": "^1.7.5",
- "@floating-ui/utils": "^0.2.11"
+ "@floating-ui/core": "^1.8.0",
+ "@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react": {
- "version": "0.27.19",
- "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz",
- "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==",
+ "version": "0.27.20",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
+ "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
"license": "MIT",
"dependencies": {
- "@floating-ui/react-dom": "^2.1.8",
- "@floating-ui/utils": "^0.2.11",
+ "@floating-ui/react-dom": "^2.1.9",
+ "@floating-ui/utils": "^0.2.12",
"tabbable": "^6.0.0"
},
"peerDependencies": {
@@ -2165,12 +2165,12 @@
}
},
"node_modules/@floating-ui/react-dom": {
- "version": "2.1.8",
- "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
- "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
+ "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
"license": "MIT",
"dependencies": {
- "@floating-ui/dom": "^1.7.6"
+ "@floating-ui/dom": "^1.8.0"
},
"peerDependencies": {
"react": ">=16.8.0",
@@ -2178,9 +2178,9 @@
}
},
"node_modules/@floating-ui/utils": {
- "version": "0.2.11",
- "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
- "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@fontsource-variable/geist": {
@@ -3147,42 +3147,45 @@
}
},
"node_modules/@mantine/core": {
- "version": "9.5.2",
- "resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.5.2.tgz",
- "integrity": "sha512-yhnR+XVGmy7S66abvj/tF9SxzNJ+k60w+UZlC/vGOztrU2Cxpbr95gtrBrin4fpWyiE80qzVC2AY0WzaHPufWw==",
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.6.0.tgz",
+ "integrity": "sha512-WpdtSv9q2k4RrAVUxBlABMyEul4vv94EkjoVuQglBBC7jVmLhBBgI64HltE6di/l6zvj9D7k1vgkbVjyxQsn1g==",
"license": "MIT",
"dependencies": {
- "@floating-ui/react": "^0.27.19",
+ "@floating-ui/react": "^0.27.20",
"clsx": "^2.1.1",
"react-number-format": "^5.4.5",
"react-remove-scroll": "^2.7.2",
"type-fest": "^5.8.0"
},
"peerDependencies": {
- "@mantine/hooks": "9.5.2",
+ "@mantine/hooks": "9.6.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/dropzone": {
- "version": "9.5.2",
- "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-9.5.2.tgz",
- "integrity": "sha512-GKDWcJAMj+SU6QtI0lpvt6Y23atBVrgWAdUYyGSEP5lDomVa/dbarsy180hSdXR0JxeWUNpG9gbcgDxPSs1n0A==",
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-9.6.0.tgz",
+ "integrity": "sha512-XvUx2Mxl2wKMUB1HoRPc+b6h5WDJ1ZPfYt4g6xDzNI4m8mnyKXmPJSgmEJD1RWzNA1CtxTX6rqRaiOUar4hbAw==",
"license": "MIT",
"dependencies": {
- "react-dropzone": "15.0.0"
+ "react-dropzone": "20.1.1"
+ },
+ "engines": {
+ "node": ">=22"
},
"peerDependencies": {
- "@mantine/core": "9.5.2",
- "@mantine/hooks": "9.5.2",
+ "@mantine/core": "9.6.0",
+ "@mantine/hooks": "9.6.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/form": {
- "version": "9.5.2",
- "resolved": "https://registry.npmjs.org/@mantine/form/-/form-9.5.2.tgz",
- "integrity": "sha512-9zWyAvEIn5QGfDa7Mxf+iCeEnUHtIam2avY8Csx6PsHqWmRSl9eYfQFrofv6FcR6eAbVUzQHzL/gpqZ3BzPt9g==",
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/@mantine/form/-/form-9.6.0.tgz",
+ "integrity": "sha512-4TbT/DiixG5uWjgm/HNQlS3Ac0Ov35WVcAW6HQ7Q4+NGuj8mED4gBXHzqVywNi7PhshivdNMox3zADvHIOyUdA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
@@ -3194,46 +3197,46 @@
}
},
"node_modules/@mantine/hooks": {
- "version": "9.5.2",
- "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.5.2.tgz",
- "integrity": "sha512-CsANdaF07VRhcvDCupIvAPtIqU1NxYSc+bhFCCasDHpgJOUQxmqeMOGCCGSjtOOEIs+E+pt2ZbbdfnygDYPybA==",
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.0.tgz",
+ "integrity": "sha512-r+1RWxc0F2XK9exKIk3Cxc17eyDTEtRqvomeQMCqI4S2Z3A78ObAGuNS8WXn8aqDGN66MJlO+WV7oCgQn2iQfQ==",
"license": "MIT",
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/@mantine/modals": {
- "version": "9.5.2",
- "resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-9.5.2.tgz",
- "integrity": "sha512-mB+A9uk6U/1+e9khNCWrblE4cvrYA3H5eKp7iuz1KE472HXWp+A4h50Wb2WcN0FDIEU7+bTHWa167LUA/5jXwA==",
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-9.6.0.tgz",
+ "integrity": "sha512-uNNwdYkk5ZqsPKi6zVe+bHq09RCM/HRbzC+UFGXmmOKq2AgOuCoLPNimvVV3y6GQ+jcPFFlkR4D7wbv4eQKPAQ==",
"license": "MIT",
"peerDependencies": {
- "@mantine/core": "9.5.2",
- "@mantine/hooks": "9.5.2",
+ "@mantine/core": "9.6.0",
+ "@mantine/hooks": "9.6.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/notifications": {
- "version": "9.5.2",
- "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-9.5.2.tgz",
- "integrity": "sha512-2DQW2i6BlTGI7Vqp1a8IN03Q2CI/nMd7joJu7RGMTDhnnD0rhAhQtu/NwGdmq5gl4vFCpzzwHtH359Do2MlRGg==",
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-9.6.0.tgz",
+ "integrity": "sha512-1sTE7EaGpr6LX7ykE8DOwpTNxi3U7AHF7Ve/oBd1VEe2k/5RmEYLTWpexxf7WwNpEmHCi99MvG7qUMQJyqc9Fg==",
"license": "MIT",
"dependencies": {
- "@mantine/store": "9.5.2",
+ "@mantine/store": "9.6.0",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
- "@mantine/core": "9.5.2",
- "@mantine/hooks": "9.5.2",
+ "@mantine/core": "9.6.0",
+ "@mantine/hooks": "9.6.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/store": {
- "version": "9.5.2",
- "resolved": "https://registry.npmjs.org/@mantine/store/-/store-9.5.2.tgz",
- "integrity": "sha512-vpOS9QwqaJ1KOPr8MdQXYXFDIPkDg1KCr5O8FOGVZIGfsei1vvQsvvmMGGEKGmEE/4FCYwEfhCUWaAVw2psVzw==",
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/@mantine/store/-/store-9.6.0.tgz",
+ "integrity": "sha512-nYt2tfn1/e6CK35a5TWlPbOL3Xwf7OzBmBElV0l+Ae6mswVaMip1WHm0mCaHAbTKgsOnvL/tooDTVxBBrmm87Q==",
"license": "MIT",
"peerDependencies": {
"react": "^19.2.0"
@@ -5768,12 +5771,12 @@
}
},
"node_modules/attr-accept": {
- "version": "2.2.5",
- "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
- "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-4.0.0.tgz",
+ "integrity": "sha512-hmCnJClmeKNKlsBHgbM8yLZRiQZ4/20UXbLJb6OUT16eWcM5/xNZerr80a/zCYob768KIGq++aLrQNTuwPsIOQ==",
"license": "MIT",
"engines": {
- "node": ">=4"
+ "node": ">= 22"
}
},
"node_modules/available-typed-arrays": {
@@ -5877,9 +5880,9 @@
}
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.38",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
- "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
+ "version": "2.10.44",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz",
+ "integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -5925,9 +5928,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.4",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
- "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
+ "version": "4.28.7",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
+ "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
"dev": true,
"funding": [
{
@@ -5945,10 +5948,10 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.10.38",
- "caniuse-lite": "^1.0.30001799",
- "electron-to-chromium": "^1.5.376",
- "node-releases": "^2.0.48",
+ "baseline-browser-mapping": "^2.10.44",
+ "caniuse-lite": "^1.0.30001806",
+ "electron-to-chromium": "^1.5.393",
+ "node-releases": "^2.0.51",
"update-browserslist-db": "^1.2.3"
},
"bin": {
@@ -6035,9 +6038,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001799",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
- "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+ "version": "1.0.30001806",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
"dev": true,
"funding": [
{
@@ -6815,9 +6818,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.377",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.377.tgz",
- "integrity": "sha512-cH1jZgJHoezfTnKfKwnScpHywTFVnJUNITDPREFdhNjiuD502+QFpG0Qk7G8jhsV/f+CEAFlIrzP1fT+IMb92g==",
+ "version": "1.5.393",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
+ "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==",
"dev": true,
"license": "ISC"
},
@@ -7482,15 +7485,12 @@
}
},
"node_modules/file-selector": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
- "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-5.0.1.tgz",
+ "integrity": "sha512-v0g/PTeuQgvKCBrVRsfVudvwXlRHSWHEQkVgKawgCGHkEpKA1clp3Om5jvEVhz8G9W/mOYjJH9FhkH4C888PgQ==",
"license": "MIT",
- "dependencies": {
- "tslib": "^2.7.0"
- },
"engines": {
- "node": ">= 12"
+ "node": ">= 22"
}
},
"node_modules/filelist": {
@@ -10393,9 +10393,9 @@
"optional": true
},
"node_modules/node-releases": {
- "version": "2.0.48",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
- "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==",
+ "version": "2.0.51",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11095,20 +11095,25 @@
}
},
"node_modules/react-dropzone": {
- "version": "15.0.0",
- "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz",
- "integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==",
+ "version": "20.1.1",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-20.1.1.tgz",
+ "integrity": "sha512-2cilRFP8bsjDOHpV0sJ6XY8pzJhmz4/cQ6s9yeckOACWYDR+n4MGGtnJh3Rycq/9SLhDWugqDx1z5mtfmOOZhw==",
"license": "MIT",
"dependencies": {
- "attr-accept": "^2.2.4",
- "file-selector": "^2.1.0",
- "prop-types": "^15.8.1"
+ "attr-accept": "^4.0.0",
+ "file-selector": "^5.0.0"
},
"engines": {
- "node": ">= 10.13"
+ "node": ">= 22"
},
"peerDependencies": {
- "react": ">= 16.8 || 18.0.0"
+ "@types/react": "*",
+ "react": ">= 18"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
"node_modules/react-is": {
diff --git a/frontend/package.json b/frontend/package.json
index 0a25ec18eb..c3e6334bb7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,12 +17,12 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.9",
- "@mantine/core": "^9.5.2",
- "@mantine/dropzone": "^9.5.2",
- "@mantine/form": "^9.5.2",
- "@mantine/hooks": "^9.5.2",
- "@mantine/modals": "^9.5.2",
- "@mantine/notifications": "^9.5.2",
+ "@mantine/core": "^9.6.0",
+ "@mantine/dropzone": "^9.6.0",
+ "@mantine/form": "^9.6.0",
+ "@mantine/hooks": "^9.6.0",
+ "@mantine/modals": "^9.6.0",
+ "@mantine/notifications": "^9.6.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.3",
diff --git a/frontend/src/components/Search.test.tsx b/frontend/src/components/Search.test.tsx
index 27f3d465d2..8fc783fd83 100644
--- a/frontend/src/components/Search.test.tsx
+++ b/frontend/src/components/Search.test.tsx
@@ -1,17 +1,180 @@
+/* eslint-disable camelcase -- API fixtures use the server's field names. */
+
+import { createMemoryRouter, RouterProvider } from "react-router";
+import { MantineProvider } from "@mantine/core";
+import { QueryClientProvider } from "@tanstack/react-query";
+import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
-import { describe, it } from "vitest";
-import { Search } from "@/components/index";
-import { customRender } from "@/tests";
+import { describe, expect, it } from "vitest";
+import queryClient from "@/apis/queries";
+import type { ArrInstance, ArrKind } from "@/apis/raw/arrInstances";
+import Search from "@/components/Search";
+import { rawRender, screen, within } from "@/tests";
import server from "@/tests/mocks/node";
+function instance(
+ id: number,
+ kind: ArrKind,
+ name: string,
+ isDefault: boolean,
+): ArrInstance {
+ return {
+ id,
+ kind,
+ name,
+ display_name: name,
+ stable_key: `${kind}-${id}`,
+ enabled: true,
+ is_default: isDefault,
+ ip: "localhost",
+ port: kind === "sonarr" ? 8989 : 7878,
+ base_url: "/",
+ ssl: false,
+ verify_ssl: true,
+ http_timeout: 60,
+ api_key_set: true,
+ };
+}
+
+const instances = [
+ instance(1, "sonarr", "HD Series", true),
+ instance(2, "sonarr", "4K Series", false),
+ instance(3, "radarr", "HD Movies", true),
+ instance(4, "radarr", "4K Movies", false),
+];
+
+const results: ItemSearchResult[] = [
+ {
+ id: 101,
+ sonarrSeriesId: 7,
+ arr_instance_id: 1,
+ title: "Café",
+ year: "2020",
+ poster: null,
+ },
+ {
+ id: 102,
+ sonarrSeriesId: 7,
+ arr_instance_id: 2,
+ title: "Café",
+ year: "2020",
+ poster: null,
+ },
+ {
+ id: 201,
+ radarrId: 7,
+ arr_instance_id: 3,
+ title: "Amélie",
+ year: "2001",
+ poster: null,
+ },
+ {
+ id: 202,
+ radarrId: 7,
+ arr_instance_id: 4,
+ title: "Amélie",
+ year: "2001",
+ poster: null,
+ },
+];
+
+function renderSearch(
+ configuredInstances = instances,
+ searchResults = results,
+) {
+ server.use(
+ http.get("/api/system/searches", () => HttpResponse.json(searchResults)),
+ http.get("/api/system/arr-instances", () =>
+ HttpResponse.json(configuredInstances),
+ ),
+ );
+
+ const router = createMemoryRouter([
+ { path: "/", element:
Movie details
}, + { path: "/series/:id", element:Series details
}, + ]); + + rawRender( +