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: 2 additions & 1 deletion .github/workflows/python-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ jobs:
BACKLOG__AI_AGENT__BASE_URL: https://example.com
BACKLOG__AI_AGENT__ACCESS_ID: 1234
BACKLOG__AI_AGENT__TOKEN: secret3

BACKLOG__OMDB__BASE_URL: "https://www.omdbapi.com"
BACKLOG__OMDB__API_KEY: ${{ secrets.OMDB_API_KEY }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
Expand Down
7 changes: 6 additions & 1 deletion backend/backlog_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ class AIAgentConfig(BaseModel):
timeout: int = 10


class OmdbConfig(BaseModel):
base_url: str
api_key: str


class Settings(BaseSettings):
model_config = SettingsConfigDict(
case_sensitive=False,
Expand Down Expand Up @@ -137,7 +142,7 @@ def settings_customise_sources(
smtp: SMTPConfig
ai_agent: AIAgentConfig
cors_origins: list[str] = ["http://localhost:5173"]
imdb_url: str = "https://api.imdbapi.dev"
omdb: OmdbConfig


settings = Settings()
141 changes: 59 additions & 82 deletions backend/backlog_app/servicies/imdb_api/provider.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import logging
from http import HTTPMethod
from typing import Any

import httpx

from backlog_app.config import settings

logger = logging.getLogger(__name__)


Expand All @@ -18,119 +19,95 @@ class TitleNotFoundError(IMDBProviderError):
class IMDBProvider:
def __init__(
self,
base_url: str,
api_key: str,
client: httpx.AsyncClient | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.client = client or httpx.AsyncClient()

async def _request(
self,
method: HTTPMethod,
endpoint: str,
*,
params: dict[str, Any] | None = None,
data: dict[str, Any] | list[Any] | None = None,
) -> dict[str, Any]:
async def _request(self, params: dict[str, Any]) -> dict[str, Any]:
try:
response = await self.client.request(
method=method,
url=f"{self.base_url}/{endpoint}",
params=params,
json=data,
response = await self.client.get(
settings.omdb.base_url,
params={"apikey": self.api_key, **params},
)

response.raise_for_status()
data = response.json()

if data.get("Response") == "False":
raise TitleNotFoundError(data.get("Error", "Not found"))

return data

return response.json()
except TitleNotFoundError:
raise

except httpx.HTTPStatusError as e:
logger.exception(
"IMDB API returned HTTP %s",
e.response.status_code,
)
logger.exception("OMDb API returned HTTP %s", e.response.status_code)
raise IMDBProviderError(
f"IMDB API returned {e.response.status_code}"
f"OMDb API returned {e.response.status_code}"
) from e

except httpx.HTTPError as e:
logger.exception("IMDB API request failed")
raise IMDBProviderError("IMDB API request failed") from e
logger.exception("OMDb API request failed")
raise IMDBProviderError("OMDb API request failed") from e

async def search_title(
self,
title: str,
limit: int = 10,
year: int | None = None,
) -> list[dict[str, Any]]:
response = await self._request(
HTTPMethod.GET,
endpoint="search/titles",
params={
"query": title,
"limit": limit,
},
)

return response.get("titles", [])
params: dict[str, Any] = {"s": title}
if year is not None:
params["y"] = year
response = await self._request(params)
return response.get("Search", [])

async def get_title_id(
self,
title: str,
year: int | None = None,
) -> str:
titles = await self.search_title(title)
results = await self.search_title(title, year)

if not results and year is not None:
logger.warning(
"No results for '%s' (%s), retrying without year filter",
title,
year,
)
results = await self.search_title(title)

if not titles:
if not results:
raise TitleNotFoundError(f"Title '{title}' not found")

if year is not None:
for item in titles:
if item.get("startYear") == year:
logger.debug(
"Found title '%s' by year %s",
title,
year,
)
return item["id"]
return results[0]["imdbID"]

async def get_title(
self,
title: str,
year: int | None = None,
) -> dict[str, Any]:
title_id = await self.get_title_id(title=title, year=year)

def popularity_score(item: dict[str, Any]) -> float:
rating = item.get("rating", {}).get("aggregateRating", 0)
logger.debug("Fetching OMDb title %s for '%s'", title_id, title)

votes = item.get("rating", {}).get("voteCount", 0)
raw = await self._request({"i": title_id, "plot": "full"})
return self._normalize(raw)

return rating * votes
def _normalize(self, raw: dict[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = {}

best_match = max(
titles,
key=popularity_score,
)
imdb_rating = raw.get("imdbRating")
if imdb_rating and imdb_rating != "N/A":
result["rating"] = {"aggregateRating": float(imdb_rating)}

logger.warning(
"No exact year match for '%s' (%s), " "using most popular result '%s'",
title,
year,
best_match.get("primaryTitle"),
)
metascore = raw.get("Metascore")
if metascore and metascore != "N/A":
result["metacritic"] = {"score": int(metascore)}

return best_match["id"]
plot = raw.get("Plot")
if plot and plot != "N/A":
result["plot"] = plot

async def get_title(
self,
title: str,
year: int | None = None,
) -> dict[str, Any]:
title_id = await self.get_title_id(
title=title,
year=year,
)

logger.debug(
"Fetching imdb title %s for '%s'",
title_id,
title,
)

return await self._request(
HTTPMethod.GET,
endpoint=f"titles/{title_id}",
)
return result
2 changes: 1 addition & 1 deletion backend/backlog_app/tasks/movie_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

logger = logging.getLogger(__name__)

provider = IMDBProvider(base_url=settings.imdb_url)
provider = IMDBProvider(api_key=settings.omdb.api_key)
translator = TranslationService()


Expand Down
33 changes: 17 additions & 16 deletions backend/tests/test_servicies/test_imdb_api/test_provider.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from http import HTTPMethod
from unittest.mock import AsyncMock

import pytest
Expand All @@ -16,7 +15,7 @@
)
@pytest.mark.asyncio
async def test_get_title_id(title, year):
imdb = IMDBProvider(base_url=settings.imdb_url)
imdb = IMDBProvider(api_key=settings.omdb.api_key)
title_id = await imdb.get_title_id(title, year)

assert title_id is not None
Expand All @@ -25,32 +24,30 @@ async def test_get_title_id(title, year):

@pytest.mark.asyncio
async def test_get_title_success(monkeypatch):
imdb = IMDBProvider(base_url="https://mocked-api.com")
imdb = IMDBProvider(api_key="test-key")

mock_get_id = AsyncMock(return_value="tt0816692")
mock_request = AsyncMock(return_value={"id": "tt0816692"})
mock_request = AsyncMock(return_value={"imdbID": "tt0816692", "Response": "True"})

monkeypatch.setattr(imdb, "get_title_id", mock_get_id)
monkeypatch.setattr(imdb, "_request", mock_request)

result = await imdb.get_title("Interstellar", 2014)

mock_get_id.assert_awaited_once_with(title="Interstellar", year=2014)
mock_request.assert_awaited_once_with(
HTTPMethod.GET,
endpoint="titles/tt0816692",
)
mock_request.assert_awaited_once_with({"i": "tt0816692", "plot": "full"})

assert result == {"id": "tt0816692"}
assert isinstance(result, dict)


@pytest.mark.asyncio
async def test_get_title_with_rating_and_metacritic(monkeypatch):
imdb = IMDBProvider(base_url="https://mocked-api.com")
imdb = IMDBProvider(api_key="test-key")

mock_title_data = {
"rating": {"aggregateRating": 8.6},
"metacritic": {"score": 74},
"imdbRating": "8.6",
"Metascore": "74",
"Response": "True",
}

monkeypatch.setattr(imdb, "get_title_id", AsyncMock(return_value="tt0816692"))
Expand All @@ -64,9 +61,13 @@ async def test_get_title_with_rating_and_metacritic(monkeypatch):

@pytest.mark.asyncio
async def test_get_title_without_metacritic(monkeypatch):
imdb = IMDBProvider(base_url="https://mocked-api.com")
imdb = IMDBProvider(api_key="test-key")

mock_title_data = {"rating": {"aggregateRating": 7.1}}
mock_title_data = {
"imdbRating": "7.1",
"Metascore": "N/A",
"Response": "True",
}

monkeypatch.setattr(imdb, "get_title_id", AsyncMock(return_value="tt0317219"))
monkeypatch.setattr(imdb, "_request", AsyncMock(return_value=mock_title_data))
Expand All @@ -79,10 +80,10 @@ async def test_get_title_without_metacritic(monkeypatch):

@pytest.mark.asyncio
async def test_get_title_empty_data(monkeypatch):
imdb = IMDBProvider(base_url="https://mocked-api.com")
imdb = IMDBProvider(api_key="test-key")

monkeypatch.setattr(imdb, "get_title_id", AsyncMock(return_value="tt0000000"))
monkeypatch.setattr(imdb, "_request", AsyncMock(return_value={}))
monkeypatch.setattr(imdb, "_request", AsyncMock(return_value={"Response": "True"}))

result = await imdb.get_title("Unknown", 0)

Expand Down
Loading