Match Jellyfin punctuation variants using provider metadata and strict fallback
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-13 17:36:31 +12:00
parent 1debf6053c
commit 765b0d2033
3 changed files with 51 additions and 15 deletions
+17 -4
View File
@@ -1,3 +1,4 @@
import re
from typing import Any, Dict, Optional
import httpx
import time
@@ -192,15 +193,27 @@ class JellyfinClient(ApiClient):
"SearchTerm": term,
"IncludeItemTypes": ",".join(item_types or []),
"Recursive": "true",
"Fields": "Path,MediaSources",
"Fields": "Path,MediaSources,ProviderIds,OriginalTitle,SortName",
"Limit": limit,
}
headers = self._emby_headers()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
result = response.json()
normalized = ' '.join(re.sub(r"[^\w\s]", ' ', term, flags=re.UNICODE).split())
terms = list(dict.fromkeys([term, normalized]))
if normalized != term and normalized.split():
terms.append(max(normalized.split(), key=len))
items = {}
for search_term in dict.fromkeys(terms):
if not search_term:
continue
response = await client.get(url, headers=headers, params={**params, "SearchTerm": search_term})
response.raise_for_status()
payload = response.json()
for item in payload.get('Items', []):
if isinstance(item, dict) and item.get('Id'):
items[item['Id']] = item
result = {'Items': list(items.values()), 'TotalRecordCount': len(items)}
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
+4 -11
View File
@@ -137,12 +137,10 @@ def jellyfin_item_matches_request(
request_provider_ids = extract_request_provider_ids(request_payload or {})
item_provider_ids = extract_request_provider_ids(item)
provider_priority = ("tmdb", "tvdb", "imdb")
for key in provider_priority:
request_id = request_provider_ids.get(key)
item_id = item_provider_ids.get(key)
if request_id and item_id and request_id == item_id:
return True
shared = set(request_provider_ids) & set(item_provider_ids)
if shared:
# Conflicting metadata must never fall through to title matching.
return all(request_provider_ids[key] == item_provider_ids[key] for key in shared)
request_title = _normalize_media_title(title)
if not request_title:
@@ -169,11 +167,6 @@ def jellyfin_item_matches_request(
if request_title in item_titles:
return True
if request_type == RequestType.tv:
for candidate in item_titles:
if candidate and (candidate.startswith(request_title) or request_title.startswith(candidate)):
return True
return False
+30
View File
@@ -0,0 +1,30 @@
import unittest
from unittest.mock import patch
import httpx
from backend.app.clients.jellyfin import JellyfinClient
from backend.app.services.snapshot import jellyfin_item_matches_request
from backend.app.models import RequestType
class JellyfinMatchingTests(unittest.IsolatedAsyncioTestCase):
async def test_search_includes_punctuation_variant_and_provider_metadata(self):
calls=[]
def handle(request):
calls.append(request)
items=[{'Id':'animated','Name':'Avatar: The Last Airbender','ProductionYear':2005,'ProviderIds':{'Tmdb':'246'}}] if ':' in request.url.params['SearchTerm'] else [{'Id':'live','Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'82452'}}]
return httpx.Response(200,json={'Items':items})
original=httpx.AsyncClient
with patch('backend.app.clients.jellyfin.httpx.AsyncClient',side_effect=lambda **kw:original(transport=httpx.MockTransport(handle),**kw)):
result=await JellyfinClient('http://jellyfin','test').search_items('Avatar: The Last Airbender',['Series'])
self.assertEqual({i['Id'] for i in result['Items']},{'live','animated'})
self.assertTrue(all('ProviderIds' in r.url.params['Fields'] for r in calls))
matches=[i for i in result['Items'] if jellyfin_item_matches_request(i,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload={'tmdbId':82452})]
self.assertEqual([i['Id'] for i in matches],['live'])
def test_fallback_rejects_remakes_prefixes_and_conflicting_ids(self):
def match(item,payload=None):
return jellyfin_item_matches_request(item,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload=payload)
self.assertTrue(match({'Name':'Avatar the Last Airbender','ProductionYear':2024}))
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2005}))
self.assertFalse(match({'Name':'Avatar','ProductionYear':2024}))
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'246'}},{'tmdbId':82452}))
self.assertTrue(match({'Name':'Localized title','ProviderIds':{'Tmdb':'82452'}},{'tmdbId':82452}))