Skip to content

Commit 57fdeed

Browse files
authored
Merge pull request #290 from LavX/development
release: Bazarr+ v2.5.2 (Murmuration)
2 parents 6fd24bb + 9f37efa commit 57fdeed

9 files changed

Lines changed: 218 additions & 15 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ jobs:
153153
tests/bazarr/test_provider_hub.py \
154154
tests/bazarr/test_provider_hub_auto_install_optin.py \
155155
tests/bazarr/test_provider_hub_archive_select.py \
156+
tests/bazarr/test_provider_hub_worker_timeout.py \
156157
tests/bazarr/test_arr_instances_schema.py \
157158
tests/bazarr/test_arr_ownership_columns.py \
158159
tests/bazarr/test_arr_instance_repository.py \

bazarr/app/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ def check_parser_binary(value):
160160
Validator('general.chmod_enabled', must_exist=True, default=False, is_type_of=bool),
161161
Validator('general.enable_strm_support', must_exist=True, default=False, is_type_of=bool),
162162
Validator('general.provider_hub_auto_install', must_exist=True, default=False, is_type_of=bool),
163+
Validator('general.provider_hub_worker_timeout', must_exist=True, default=120, is_type_of=int,
164+
gte=30, lte=86400),
163165
Validator('general.chmod', must_exist=True, default='0640', is_type_of=str),
164166
Validator('general.subfolder', must_exist=True, default='current', is_type_of=str),
165167
Validator('general.subfolder_custom', must_exist=True, default='', is_type_of=str),

bazarr/provider_hub/registry.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
logger = logging.getLogger(__name__)
2525

2626
_REGISTERED_PROVIDER_HUB_IDS: set[str] = set()
27-
_MAX_WORKER_REQUEST_TIMEOUT = 3600.0
27+
_MAX_WORKER_REQUEST_TIMEOUT = 86400.0
28+
_HOST_TIMEOUT_MARGIN_SECONDS = 30.0
29+
_DEFAULT_GLOBAL_WORKER_TIMEOUT = 120.0
2830

2931

3032
def _coerce_timeout(value):
@@ -37,14 +39,30 @@ def _coerce_timeout(value):
3739
return timeout
3840

3941

42+
def _global_worker_timeout():
43+
"""Backstop host-side worker deadline from settings, with a defensive fallback.
44+
45+
Runs host-side, so app.config is importable. Falls back to the built-in
46+
default if settings are unavailable or the value is missing/invalid."""
47+
try:
48+
from app.config import settings
49+
value = _coerce_timeout(getattr(settings.general, "provider_hub_worker_timeout", None))
50+
except Exception:
51+
return _DEFAULT_GLOBAL_WORKER_TIMEOUT
52+
return value or _DEFAULT_GLOBAL_WORKER_TIMEOUT
53+
54+
4055
class HubProxyProvider(Provider):
4156
provider_name = "providerhub"
4257
languages = set()
4358
video_types = (Episode, Movie)
4459
subtitle_class = None
4560

46-
def __init__(self, timeout=30, worker_client=None, **config):
47-
self.timeout = _coerce_timeout(timeout) or 30.0
61+
def __init__(self, timeout=None, worker_client=None, **config):
62+
# An explicit per-instance timeout override is optional; when absent the
63+
# global default backstop (see _request_timeout) applies instead of a
64+
# hardcoded value.
65+
self.timeout = _coerce_timeout(timeout)
4866
self.worker_client = worker_client or getattr(self.__class__, "worker_client", None)
4967
self.config = config
5068

@@ -79,12 +97,37 @@ def _worker(self):
7997
raise WorkerError("Provider Hub worker is not configured")
8098

8199
def _request_timeout(self):
82-
timeout = self.timeout
100+
# The host deadline must never fire before the worker's own per-operation
101+
# timeout, or a long transcription is killed regardless of the user's
102+
# settings (issue: WhisperAI and 30 second worker timeout). Derive the
103+
# deadline from every timeout the plugin config declares. A margin is
104+
# added on top so a single-operation worker reaches its own timeout and
105+
# returns a clean error before the host read-wall kills the subprocess.
106+
# (A multi-phase worker, e.g. whisper's extract-then-transcribe, can
107+
# still exceed the host wall; each phase is bounded by the worker's own
108+
# per-phase timeout, so this is an accepted limitation, not a hard-kill
109+
# regression of the 30s bug.) The plugin-declared value is clamped to the
110+
# maximum BEFORE the margin is added so the margin survives at the cap,
111+
# then floored by the global default backstop.
112+
declared = []
113+
explicit = _coerce_timeout(self.timeout)
114+
if explicit is not None:
115+
declared.append(explicit)
83116
for key in ("worker_timeout", "timeout_seconds", "timeout"):
84-
configured = _coerce_timeout(self.config.get(key))
85-
if configured is not None:
86-
timeout = max(timeout, configured)
87-
return min(timeout, _MAX_WORKER_REQUEST_TIMEOUT)
117+
value = _coerce_timeout(self.config.get(key))
118+
if value is not None:
119+
declared.append(value)
120+
for key, raw in self.config.items():
121+
if key.endswith("_timeout_seconds"):
122+
value = _coerce_timeout(raw)
123+
if value is not None:
124+
declared.append(value)
125+
126+
global_default = _global_worker_timeout()
127+
if not declared:
128+
return global_default
129+
base = min(max(declared), _MAX_WORKER_REQUEST_TIMEOUT)
130+
return max(base + _HOST_TIMEOUT_MARGIN_SECONDS, global_default)
88131

89132
def list_subtitles(self, video, languages):
90133
timeout = self._request_timeout()

frontend/src/data/whatsNew.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,23 @@ export interface WhatsNewSlide {
2828
* cutting a release. Kept as an explicit token so the wizard never has to parse the
2929
* fork's `version + YYMMDD` runtime string.
3030
*/
31-
export const latestWhatsNewVersion = "2.5.1";
31+
export const latestWhatsNewVersion = "2.5.2";
3232

3333
export const whatsNew: Record<string, WhatsNewSlide[]> = {
34+
"2.5.2": [
35+
{
36+
title: "WhisperAI timeouts no longer cut off at 30 seconds",
37+
body: "WhisperAI transcriptions used to be killed after 30 seconds no matter what response or transcription timeouts you set. Your configured timeouts now drive how long the provider is given, so long jobs run to completion.",
38+
icon: faWandMagicSparkles,
39+
cta: { label: "Open Providers", to: "/settings/providers" },
40+
},
41+
{
42+
title: "Configurable Provider Hub worker timeout",
43+
body: "A new Default worker timeout setting under General > Provider Hub sets the fallback deadline for Hub plugins that do not define their own. Plugins like WhisperAI that declare a longer timeout raise it above this value.",
44+
icon: faSliders,
45+
cta: { label: "Open General settings", to: "/settings/general" },
46+
},
47+
],
3448
"2.5.1": [
3549
{
3650
title: "Cleaner multi-server first-run",

frontend/src/pages/Settings/General/index.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,19 @@ const SettingsGeneralView: FunctionComponent = () => {
263263
Installing or replacing providers manually from the Provider Hub
264264
Marketplace always works regardless of this setting.
265265
</Message>
266+
<Number
267+
label="Default worker timeout (seconds)"
268+
settingKey="settings-general-provider_hub_worker_timeout"
269+
min={30}
270+
max={86400}
271+
step={30}
272+
></Number>
273+
<Message>
274+
Minimum time a Provider Hub plugin is given to answer a search or
275+
download before Bazarr gives up. Plugins that declare a longer timeout
276+
(for example WhisperAI's transcription timeout) raise the deadline
277+
above this value. Default 120 seconds.
278+
</Message>
266279
</Section>
267280
<Section header="Proxy">
268281
<Selector

package_info

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Bazarr+ Package Information
22

33
# Package version identifier (shown in System Status)
4-
packageversion=Bazarr+ v2.5.1
4+
packageversion=Bazarr+ v2.5.2
55

66
# Package author (shown in System Status)
77
packageauthor=LavX

site/index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"url": "https://lavx.github.io/bazarr/",
3838
"image": "https://lavx.github.io/bazarr/screenshots/og-image.png",
3939
"downloadUrl": "https://github.com/LavX/bazarr",
40-
"softwareVersion": "2.5.1",
40+
"softwareVersion": "2.5.2",
4141
"author": {
4242
"@type": "Person",
4343
"name": "LavX",
@@ -524,6 +524,7 @@ <h2 id="roadmap-heading">What's next</h2>
524524
<li><strong>v2.4.0 &ldquo;Prism&rdquo;</strong> (released): the Distribution Hub multi-tenant subtitle API, Provider Hub built-in replacement with startup auto-install, combined bilingual and trilingual subtitles, embedded-track scoring, and host-side archive extraction.</li>
525525
<li><strong>v2.5.0 &ldquo;Murmuration&rdquo;</strong> (released): <a href="https://github.com/LavX/bazarr/issues/156" target="_blank" rel="noopener">multiple Radarr and Sonarr instances</a> managed as one flock, per-instance subtitle settings, compressed-archive uploads with drag-and-drop, and an SSRF / path-traversal security pass.</li>
526526
<li><strong>v2.5.1 &ldquo;Murmuration&rdquo;</strong> (released): a stability patch for the multi-instance rollout. Fresh-setup connection-refused noise is fixed and self-heals on upgrade, reverse-proxy subpath asset loading works again, plus a full round of dependency and security maintenance.</li>
527+
<li><strong>v2.5.2 &ldquo;Murmuration&rdquo;</strong> (released): a Provider Hub patch. WhisperAI transcriptions are no longer killed at a hardcoded 30 seconds; the deadline now follows the timeouts you configure, with a new global default worker-timeout setting as the fallback.</li>
527528
<li><strong>v2.6.0</strong>: universal providers and federation. Point Bazarr+ at any OpenSubtitles.com-compatible endpoint, including another Bazarr+ instance, and daisy-chain instances together over a loop-safe federation protocol.</li>
528529
<li><strong>v3.0.0 &ldquo;Subnet&rdquo;</strong>: peer-to-peer subtitle federation, a self-organizing mesh of Bazarr+ instances.</li>
529530
</ul>

tests/bazarr/test_provider_hub.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2345,11 +2345,16 @@ def test_bundle_tree_verification_rejects_bundle_hash_mismatch(tmp_path):
23452345
verify_bundle_tree(manifest, tmp_path)
23462346

23472347

2348-
def test_hub_proxy_provider_search_and_download_uses_worker_payload():
2348+
def test_hub_proxy_provider_search_and_download_uses_worker_payload(monkeypatch):
2349+
from provider_hub import registry as registry_module
23492350
from provider_hub.manifest import validate_manifest
23502351
from provider_hub.registry import _make_provider_class
23512352
from provider_hub.protocol import language_to_payload
23522353

2354+
# Pin the global default so the timeout assertion below is a concrete value
2355+
# (an explicit timeout=9 is below the 120s floor -> raised to 120).
2356+
monkeypatch.setattr(registry_module, "_global_worker_timeout", lambda: 120.0)
2357+
23532358
class FakeWorker:
23542359
def __init__(self):
23552360
self.requests = []
@@ -2412,7 +2417,9 @@ def stop(self):
24122417
provider.download_subtitle(subtitles[0])
24132418
assert subtitles[0].content == b"hello"
24142419
assert worker.requests[0][0] == "search"
2415-
assert worker.requests[0][2] == 9
2420+
# The worker request carries the host-side deadline from _request_timeout();
2421+
# an explicit timeout (9) below the global default floor is raised to 120.
2422+
assert worker.requests[0][2] == 120.0
24162423
assert worker.requests[0][1]["config"] == provider_config
24172424
assert worker.requests[1][0] == "download"
24182425
assert worker.requests[1][1]["config"] == provider_config
@@ -2482,8 +2489,10 @@ def stop(self):
24822489

24832490
provider.download_subtitle(subtitle)
24842491

2485-
assert worker.requests[0] == ("search", 600)
2486-
assert worker.requests[1] == ("download", 600)
2492+
# 600s configured + 30s host margin so the worker's own timeout fires first.
2493+
assert provider._request_timeout() == 630.0
2494+
assert worker.requests[0] == ("search", 630.0)
2495+
assert worker.requests[1] == ("download", 630.0)
24872496

24882497

24892498
def test_hub_proxy_download_invokes_select_archive_member():
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# -*- coding: utf-8 -*-
2+
from types import SimpleNamespace
3+
4+
import pytest
5+
6+
import provider_hub.registry as registry
7+
from provider_hub.registry import HubProxyProvider
8+
9+
10+
WHISPER_CONFIG = {
11+
"endpoint": "http://127.0.0.1:9000",
12+
"ffmpeg_path": "ffmpeg",
13+
"pass_video_name": False,
14+
"response_timeout_seconds": 600,
15+
"transcription_timeout_seconds": 3600,
16+
}
17+
18+
19+
def _pin_global(monkeypatch, value):
20+
monkeypatch.setattr(registry, "_global_worker_timeout", lambda: float(value))
21+
22+
23+
def test_worker_timeout_validator_default():
24+
from app.config import settings
25+
26+
assert int(settings.general.provider_hub_worker_timeout) == 120
27+
28+
29+
def test_declared_transcription_timeout_drives_deadline(monkeypatch):
30+
_pin_global(monkeypatch, 120)
31+
provider = HubProxyProvider(**WHISPER_CONFIG)
32+
# 3600 declared + 30 margin, floored by 120, under the 86400 cap.
33+
assert provider._request_timeout() == 3630.0
34+
35+
36+
def test_no_declared_timeout_uses_global_default(monkeypatch):
37+
_pin_global(monkeypatch, 120)
38+
provider = HubProxyProvider(endpoint="http://x", api_key="secret")
39+
assert provider._request_timeout() == 120.0
40+
41+
42+
def test_global_default_is_a_floor(monkeypatch):
43+
_pin_global(monkeypatch, 5000)
44+
provider = HubProxyProvider(**WHISPER_CONFIG)
45+
# derived 3630 < global default 5000 -> floor wins.
46+
assert provider._request_timeout() == 5000.0
47+
48+
49+
def test_declared_timeout_clamped_to_cap(monkeypatch):
50+
_pin_global(monkeypatch, 120)
51+
# A plugin timeout above the cap is clamped to _MAX_WORKER_REQUEST_TIMEOUT
52+
# before the margin is added, so the host wall is cap + margin. Defensive
53+
# only: the setting validator and plugin manifests already bound configured
54+
# values to the cap, so a value this high should not reach here in practice.
55+
provider = HubProxyProvider(endpoint="http://x", transcription_timeout_seconds=90000)
56+
assert provider._request_timeout() == registry._MAX_WORKER_REQUEST_TIMEOUT + 30.0
57+
58+
59+
def test_margin_preserved_at_the_cap(monkeypatch):
60+
_pin_global(monkeypatch, 120)
61+
# Exactly at the manifest maximum, the +30 host margin must not be swallowed
62+
# by the cap (regression guard: host deadline > worker's own timeout).
63+
provider = HubProxyProvider(endpoint="http://x", transcription_timeout_seconds=86400)
64+
assert provider._request_timeout() == 86430.0
65+
66+
67+
def test_explicit_constructor_timeout_override(monkeypatch):
68+
_pin_global(monkeypatch, 120)
69+
# An explicit timeout= above the floor drives the deadline (this is the only
70+
# path exercising the self.timeout contribution to `declared`).
71+
provider = HubProxyProvider(endpoint="http://x", timeout=5000)
72+
assert provider._request_timeout() == 5030.0
73+
74+
75+
def test_legacy_worker_timeout_key_still_honored(monkeypatch):
76+
_pin_global(monkeypatch, 120)
77+
provider = HubProxyProvider(endpoint="http://x", worker_timeout=900)
78+
assert provider._request_timeout() == 930.0
79+
80+
81+
def test_legacy_and_suffix_keys_take_the_larger(monkeypatch):
82+
_pin_global(monkeypatch, 120)
83+
# Both timeout categories present with the legacy key larger, so a refactor
84+
# that let the *_timeout_seconds loop overwrite (rather than extend) the
85+
# declared list would drop to 630 and fail here.
86+
provider = HubProxyProvider(
87+
endpoint="http://x", worker_timeout=3600, transcription_timeout_seconds=600
88+
)
89+
assert provider._request_timeout() == 3630.0
90+
91+
92+
def test_registry_cap_covers_the_validator_maximum():
93+
# The host cap must accommodate the largest value the setting/manifest allow
94+
# (validator lte and whisper manifest maximum are both 86400); otherwise a
95+
# user's configured timeout would be silently clipped below what they set.
96+
assert registry._MAX_WORKER_REQUEST_TIMEOUT >= 86400
97+
98+
99+
def test_global_worker_timeout_reads_setting(monkeypatch):
100+
monkeypatch.setattr(
101+
"app.config.settings",
102+
SimpleNamespace(general=SimpleNamespace(provider_hub_worker_timeout=500)),
103+
raising=False,
104+
)
105+
assert registry._global_worker_timeout() == 500.0
106+
107+
108+
@pytest.mark.parametrize("bad_value", [None, 0, -5, "oops"])
109+
def test_global_worker_timeout_falls_back(monkeypatch, bad_value):
110+
# Missing, zero, negative, and non-numeric all fall back to the default via
111+
# _coerce_timeout; the docstring promises this for "missing/invalid".
112+
general = SimpleNamespace() if bad_value is None else SimpleNamespace(
113+
provider_hub_worker_timeout=bad_value
114+
)
115+
monkeypatch.setattr(
116+
"app.config.settings",
117+
SimpleNamespace(general=general),
118+
raising=False,
119+
)
120+
assert registry._global_worker_timeout() == 120.0

0 commit comments

Comments
 (0)