Skip to content

Commit 398ebe4

Browse files
authored
Merge pull request #14 from unknown0152/fix/learn-imported-nzb-id-collision
fix(proxy): synthesize stable nzb_id for first-time-seen /learn/imported
2 parents 35d05b4 + 93f0498 commit 398ebe4

4 files changed

Lines changed: 82 additions & 6 deletions

File tree

arr-ffprobe-real-install.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ PROW_KEY=${PROWLARR_API_KEY:-19e327d9fccc40d9be7bd0f87eb39b04}
2020
# ---------- 1. Extract real ffprobe ---------------------------------------
2121
if [ ! -x "$FFPROBE_REAL" ]; then
2222
echo "[1/3] Extracting real ffprobe binary..."
23-
# Use linuxserver/sonarr (which ships a working ffprobe before our stub overlays it)
23+
# linuxserver/sonarr ships ffprobe at /app/sonarr/bin/ffprobe (radarr image
24+
# has it at /app/radarr/bin/ffprobe — either works since both are static).
2425
cid=$(docker create lscr.io/linuxserver/sonarr:latest)
25-
docker cp "$cid:/usr/bin/ffprobe" "$FFPROBE_REAL"
26+
docker cp "$cid:/app/sonarr/bin/ffprobe" "$FFPROBE_REAL"
2627
docker rm "$cid" >/dev/null
2728
chmod +x "$FFPROBE_REAL"
2829
echo " -> $FFPROBE_REAL ($("$FFPROBE_REAL" -version | head -1))"

arr-on-import-learn.sh

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@ release="${radarr_moviefile_scenename:-$sonarr_episodefile_scenename}"
2828
[ -z "$release" ] && exit 0
2929
[ -f "$file" ] || exit 0
3030

31-
# Real ffprobe — bind-mounted into the container at /opt/ffprobe-real
32-
ffprobe_bin=/opt/ffprobe-real
31+
# Real ffprobe — placed at /config/bin/ffprobe-real by the install scaffold
32+
# (the /config bind-mount that Sonarr/Radarr already have, no Cosmos compose
33+
# changes needed). Falls back to /opt/ffprobe-real if a bind-mount install
34+
# is used instead.
35+
ffprobe_bin=/config/bin/ffprobe-real
36+
[ -x "$ffprobe_bin" ] || ffprobe_bin=/opt/ffprobe-real
3337
[ -x "$ffprobe_bin" ] || exit 0
3438

3539
# Run ffprobe; bail silently if it errors

dksubs-proxy.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -691,9 +691,16 @@ async def _handle_learn_imported(request) -> "web.Response":
691691
log(f"/learn/imported audit insert failed: {e!r}", "WARN")
692692

693693
# Update cache (ffprobe overrides any prior source).
694-
# Re-use the existing nzb_id so INSERT OR REPLACE updates the same row.
694+
# If the release was already known, re-use its nzb_id so INSERT OR
695+
# REPLACE updates the same row. If NOT known (no previous entry), an
696+
# empty nzb_id would collide with every other first-time-seen learn
697+
# event because nzb_id is the primary key. Synthesize a stable
698+
# release-name-derived ID instead: `ffp:<sha256(release_name)[:16]>`.
699+
cache_nzb_id = previous_nzb_id or (
700+
"ffp:" + hashlib.sha256(release_name.encode("utf-8")).hexdigest()[:16]
701+
)
695702
try:
696-
await cache_set(previous_nzb_id, actual_tag, release_name, source="ffprobe")
703+
await cache_set(cache_nzb_id, actual_tag, release_name, source="ffprobe")
697704
except Exception as e:
698705
log(f"/learn/imported cache_set failed: {e!r}", "WARN")
699706

tests/test_learn_endpoint.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,67 @@ async def test_learn_endpoint_mismatch_classification(proxy, cache_db, monkeypat
115115
assert result == expected_mismatch, (
116116
f"predicted={predicted} actual={actual} expected={expected_mismatch} got={result}"
117117
)
118+
119+
120+
@pytest.mark.asyncio
121+
async def test_learn_endpoint_synthesizes_nzb_id_when_release_is_new(
122+
proxy, cache_db, monkeypatch,
123+
):
124+
"""Two different releases with NO previous cache entry must NOT collide
125+
on the same empty primary key. Each gets a stable synthetic nzb_id
126+
derived from its release_name so INSERT OR REPLACE doesn't clobber
127+
the other."""
128+
monkeypatch.setattr(proxy, "PROWLARR_API_KEY", "k")
129+
130+
from aiohttp.test_utils import make_mocked_request
131+
132+
async def post(body_json: str):
133+
req = make_mocked_request(
134+
"POST", "/learn/imported",
135+
headers={"X-Api-Key": "k", "Content-Type": "application/json"},
136+
)
137+
async def text(): return body_json
138+
req.text = text
139+
return await proxy._handle_learn_imported(req)
140+
141+
# First release — new to cache
142+
resp1 = await post(json.dumps({
143+
"release_name": "FirstRelease.2026.NORDiC.1080p-FOO",
144+
"audio_languages": ["dan", "eng"],
145+
"subtitle_languages": ["dan"],
146+
}))
147+
assert resp1.status == 200
148+
149+
# Second release — also new to cache
150+
resp2 = await post(json.dumps({
151+
"release_name": "SecondRelease.2026.NORDiC.1080p-BAR",
152+
"audio_languages": ["dan", "eng"],
153+
"subtitle_languages": ["dan"],
154+
}))
155+
assert resp2.status == 200
156+
157+
# BOTH releases must be present in nfo_cache after the two POSTs.
158+
# Pre-fix bug: both writes used nzb_id='' which is the PK, so the
159+
# second INSERT OR REPLACE clobbered the first.
160+
async with cache_db.execute(
161+
"SELECT nzb_id, release_name FROM nfo_cache "
162+
"WHERE release_name IN (?, ?)",
163+
("FirstRelease.2026.NORDiC.1080p-FOO",
164+
"SecondRelease.2026.NORDiC.1080p-BAR"),
165+
) as cur:
166+
rows = await cur.fetchall()
167+
168+
rel_names = {r[1] for r in rows}
169+
assert "FirstRelease.2026.NORDiC.1080p-FOO" in rel_names, (
170+
f"first release lost from cache: {rows!r}"
171+
)
172+
assert "SecondRelease.2026.NORDiC.1080p-BAR" in rel_names
173+
174+
# The synthetic IDs must be distinct (no PK collision)
175+
ids = {r[0] for r in rows}
176+
assert len(ids) == 2, f"expected 2 distinct nzb_ids, got: {ids!r}"
177+
# Synthetic IDs should be predictable: ffp:<hash>
178+
for nid in ids:
179+
assert nid.startswith("ffp:"), (
180+
f"new-release nzb_id should be synthetic 'ffp:...', got {nid!r}"
181+
)

0 commit comments

Comments
 (0)