diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py index fa97476..9efed09 100644 --- a/backend/app/clients/jellyfin.py +++ b/backend/app/clients/jellyfin.py @@ -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, diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index bf9bb26..863f5b1 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -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 diff --git a/backend/tests/test_jellyfin_matching.py b/backend/tests/test_jellyfin_matching.py new file mode 100644 index 0000000..0a55dbd --- /dev/null +++ b/backend/tests/test_jellyfin_matching.py @@ -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}))