Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ breaking config changes.
### Security
- **The published image no longer ships pip.** Nothing in a running subarr container installs packages, so pip was only ever needed while the image was being built, but it stayed in the finished image carrying six published advisories. The most serious of them lets a malicious package overwrite files outside its install directory during an install. It was invisible to the patching already in place: the build refreshes Debian packages every time, and pip is not a Debian package here, so that refresh could never see it. Upgrading pip does not fix it either, because pip carries its own dependencies bundled inside itself and every release to date pins a version of one of them with its own unfixed advisory, so an upgrade trades six findings for two. pip is now removed once it has finished installing subarr, which clears all six and introduces none. Verified by scanning the published image, an upgrade-only build and this one. The application itself is completely unchanged.

### Fixed
- **The Settings page asked Ollama for the same model list twice on every refresh.** The integrations health check reads your installed models for the status panel, then worked out which vision model to use by asking Ollama for that exact list a second time. That page refreshes every 8 seconds, so it was a permanently doubled request rate for data it was already holding. It now reuses what it just fetched. Nothing you see changes, and it still notices a model you pull outside subarr.

## [2.6.1] - 2026-09-03

**Multi-library and multi-Sonarr setups could act on the wrong file.**
Expand Down
14 changes: 11 additions & 3 deletions src/subarr/integrations/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ async def installed_models(self) -> list[str]:
models = data.get("models", []) if isinstance(data, dict) else []
return [m.get("name", "") for m in models if isinstance(m, dict)]

async def resolve_vision_model(self) -> str | None:
async def resolve_vision_model(self, installed: list[str] | None = None) -> str | None:
"""#232: figure out which vision-capable model to use right now.

Logic:
Expand All @@ -216,13 +216,21 @@ async def resolve_vision_model(self) -> str | None:
state, the text model would hallucinate.

Result is cached on the instance until reset_vision_cache() is
called (Settings save / model pull completion clears it)."""
called (Settings save / model pull completion clears it).

`installed` lets a caller that has ALREADY fetched /api/tags hand the
model names in rather than making us fetch the identical payload a
second time. The integrations-health probe does exactly that: it reads
the model list for its badges, resets this cache so an externally
pulled model is noticed, and would otherwise re-GET /api/tags on every
poll for data it is already holding."""
if self._vision_model_resolved is not None:
return self._vision_model_resolved or None
if not self._configured:
self._vision_model_resolved = ""
return None
installed = await self.installed_models()
if installed is None:
installed = await self.installed_models()
if not installed:
self._vision_model_resolved = ""
return None
Expand Down
8 changes: 7 additions & 1 deletion src/subarr/routers/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,14 @@ async def _probe(name: str, client, summary_kind: str = "version") -> dict[str,
# panel can show "Vision pre-filter active / inactive" with
# the resolved model name, instead of users discovering it
# only when a vision call fails.
# Reset so a model pulled outside subarr is picked up, then hand
# resolve_vision_model the names from the tags call above. Without
# that argument it re-GETs /api/tags for the same payload, which at
# the Settings page's 8s poll doubled the request rate forever.
client.reset_vision_cache()
vision_resolved = await client.resolve_vision_model()
vision_resolved = await client.resolve_vision_model(
installed=[m.get("name", "") for m in models if isinstance(m, dict)]
)
return {
"name": name,
"online": True,
Expand Down
41 changes: 41 additions & 0 deletions tests/test_integrations_health_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

from __future__ import annotations

import httpx
import pytest

from subarr.integrations import IntegrationError
from subarr.integrations.ollama import OllamaClient
from subarr.routers.integrations import _probe


Expand Down Expand Up @@ -84,3 +86,42 @@ async def test_both_fail_marks_offline_no_dangling_task():
"bazarr_badges",
)
assert out["online"] is False


# The ollama probe must cost ONE /api/tags per poll, not two.


@pytest.mark.asyncio
async def test_ollama_probe_fetches_tags_once():
"""The ollama probe used to GET /api/tags TWICE on every poll: once for the
model list, then it reset the vision cache and called resolve_vision_model(),
which re-fetched the identical payload through installed_models(). The
Settings page polls this every 8s, so that is a doubled request forever for
data already in hand. The resolution itself must still work, hence the
vision_model_resolved assertion below."""
calls: list[str] = []

def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
if request.url.path == "/api/tags":
return httpx.Response(
200,
json={"models": [{"name": "qwen2.5vl:7b"}, {"name": "qwen2.5:7b"}]},
)
if request.url.path == "/api/version":
return httpx.Response(200, json={"version": "0.33.3"})
return httpx.Response(404)

c = OllamaClient(base_url="http://ollama.test", model="qwen2.5:7b", vision_model="auto")
c._configured = True
c._client = httpx.AsyncClient(base_url="http://ollama.test", transport=httpx.MockTransport(handler))

out = await _probe("ollama", c, "ollama_models")

assert out["online"] is True
assert out["badges"]["models"] == 2
assert out["badges"]["vision_model_resolved"] == "qwen2.5vl:7b"
assert calls.count("/api/tags") == 1, "expected 1 /api/tags per probe, got %d: %r" % (
calls.count("/api/tags"),
calls,
)
Loading