diff --git a/backend/backlog_app/api/view/movie_view.py b/backend/backlog_app/api/view/movie_view.py index 691134e..ebb1221 100644 --- a/backend/backlog_app/api/view/movie_view.py +++ b/backend/backlog_app/api/view/movie_view.py @@ -11,7 +11,7 @@ from backlog_app.models.users import User from backlog_app.schemas.movie import MovieCreate, MovieList, MovieRead, MovieUpdate from backlog_app.storages.database import get_async_session -from backlog_app.tasks.movie_task import update_movie_description, update_movie_rating +from backlog_app.tasks.movie_task import update_movie_metadata router = APIRouter(prefix="/movies", tags=["Movies"]) @@ -24,9 +24,7 @@ async def add_movie( background_tasks: BackgroundTasks, ): movie = await crud.create_movie(db, movie_create, user=user) - background_tasks.add_task(update_movie_rating, movie, db, user) - background_tasks.add_task(update_movie_description, movie, db, user) - + background_tasks.add_task(update_movie_metadata, movie.slug, user.id) return movie diff --git a/backend/backlog_app/servicies/imdb_api/provider.py b/backend/backlog_app/servicies/imdb_api/provider.py index 5808fab..b109144 100644 --- a/backend/backlog_app/servicies/imdb_api/provider.py +++ b/backend/backlog_app/servicies/imdb_api/provider.py @@ -1,101 +1,136 @@ -import asyncio import logging from http import HTTPMethod +from typing import Any import httpx -from fastapi import HTTPException logger = logging.getLogger(__name__) -class IMDBProvider: - """ - IMDB API provider - """ +class IMDBProviderError(Exception): + """Base IMDB provider exception.""" + - def __init__(self, base_url: str) -> None: - self.base_url = base_url +class TitleNotFoundError(IMDBProviderError): + """Movie or TV show was not found.""" + + +class IMDBProvider: + def __init__( + self, + base_url: str, + client: httpx.AsyncClient | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.client = client or httpx.AsyncClient() async def _request( self, method: HTTPMethod, endpoint: str, - params: dict | None = None, - data: dict | list | None = None, - ) -> dict: - url = f"{self.base_url}/{endpoint}" - async with httpx.AsyncClient() as client: - try: - response = await client.request( - method=method, - url=url, - params=params, - json=data, - ) - response.raise_for_status() - return response.json() - except httpx.HTTPError as e: - status_code = getattr(e.response, "status_code", 500) - logger.error( - "IMDB API error. Status code: %s, detail: %s", status_code, e - ) - raise HTTPException( - status_code=status_code, detail="SERVER_ERROR" - ) from e - - async def get_title_id(self, title: str, year: int) -> str: - """ - title identifier is of type str, because the imdb identifier is tt0816692 - """ - params = {"query": title, "limit": 10} + *, + params: dict[str, Any] | None = None, + data: dict[str, Any] | list[Any] | None = None, + ) -> dict[str, Any]: + try: + response = await self.client.request( + method=method, + url=f"{self.base_url}/{endpoint}", + params=params, + json=data, + ) + + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + logger.exception( + "IMDB API returned HTTP %s", + e.response.status_code, + ) + raise IMDBProviderError( + f"IMDB 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 + + async def search_title( + self, + title: str, + limit: int = 10, + ) -> list[dict[str, Any]]: response = await self._request( HTTPMethod.GET, endpoint="search/titles", - params=params, + params={ + "query": title, + "limit": limit, + }, ) - titles = response.get("titles") + + return response.get("titles", []) + + async def get_title_id( + self, + title: str, + year: int | None = None, + ) -> str: + titles = await self.search_title(title) + if not titles: - raise HTTPException(status_code=404, detail="Title not found") - - logger.debug("TITLE YEAR: %s", year) - if year: - for t in titles: - if t.get("startYear") == year: - logger.debug("Found match by year: %s", t) - return t["id"] - - def popularity_score(t): - rating = t.get("rating", {}).get("aggregateRating", 0) - votes = t.get("rating", {}).get("voteCount", 0) - return rating * votes + raise TitleNotFoundError(f"Title '{title}' not found") - best_match = max(titles, key=popularity_score) - logger.warning( - "No title found for year %s, using most popular match: %s", year, best_match - ) - return best_match["id"] + 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"] - async def get_title(self, title: str, year: int) -> dict: - title_id = await self.get_title_id(title, year) - return await self._request(HTTPMethod.GET, endpoint=f"titles/{title_id}") + def popularity_score(item: dict[str, Any]) -> float: + rating = item.get("rating", {}).get("aggregateRating", 0) - async def get_title_rating(self, title: str, year: int) -> tuple[float, float]: - title_data = await self.get_title(title, year) + votes = item.get("rating", {}).get("voteCount", 0) - rating = title_data.get("rating", {}) - metacritic_rating = title_data.get("metacritic", {}) + return rating * votes - logger.debug("Found title info: %s", title_data) - logger.debug("Title Rating: %s, %s", rating, metacritic_rating) + best_match = max( + titles, + key=popularity_score, + ) - return rating.get("aggregateRating"), metacritic_rating.get("score") + logger.warning( + "No exact year match for '%s' (%s), " "using most popular result '%s'", + title, + year, + best_match.get("primaryTitle"), + ) - async def get_title_description(self, title: str, year: int) -> str: - title_data = await self.get_title(title, year) + return best_match["id"] - description = title_data.get("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("Found title info: %s", title_data) - logger.debug("Title Description: %s", description) + logger.debug( + "Fetching imdb title %s for '%s'", + title_id, + title, + ) - return description + return await self._request( + HTTPMethod.GET, + endpoint=f"titles/{title_id}", + ) diff --git a/backend/backlog_app/tasks/movie_task.py b/backend/backlog_app/tasks/movie_task.py index 6e8cb6e..eb876e5 100644 --- a/backend/backlog_app/tasks/movie_task.py +++ b/backend/backlog_app/tasks/movie_task.py @@ -1,14 +1,16 @@ import logging +from uuid import UUID -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select from backlog_app.api import crud from backlog_app.api.crud import partial_update_movie from backlog_app.config import settings -from backlog_app.models import User -from backlog_app.schemas.movie import MovieRead, MovieUpdate +from backlog_app.models.users import User as UserModel +from backlog_app.schemas.movie import MovieUpdate from backlog_app.servicies.ai_agent import TranslationService from backlog_app.servicies.imdb_api.provider import IMDBProvider +from backlog_app.storages.database import AsyncSessionLocal logger = logging.getLogger(__name__) @@ -16,75 +18,80 @@ translator = TranslationService() -async def update_movie_rating(movie: MovieRead, db: AsyncSession, user: User): - movie_db = await crud.get_movie_by_slug(db, movie.slug) - year = getattr(movie, "year", None) - - if year is None: - logger.info("Movie <%s> has no year, skipping rating update", movie.slug) - return - - try: - imdb_rating, metacritic_score = await provider.get_title_rating( - movie_db.title, movie_db.year - ) - except Exception as e: - logger.exception("Failed to fetch rating for movie <%s>: %s", movie.slug, e) - return - - await partial_update_movie( - db, - movie.slug, - MovieUpdate( - imdb_rating=imdb_rating, - metacritic_score=metacritic_score, - ), - user, - ) - - logger.info("Movie <%s> ratings updated in background", movie.slug) - - -async def update_movie_description( - movie: MovieRead, db: AsyncSession, user: User +async def update_movie_metadata( + movie_slug: str, + user_id: UUID, ) -> None: - - movie_db = await crud.get_movie_by_slug(db, movie.slug) - - description = getattr(movie_db, "description", None) - year = getattr(movie_db, "year", None) - - if description and description.strip(): - logger.info("Movie <%s> already has description, skipping", movie.slug) - return - - if not year: - logger.info("Movie <%s> has no year, skipping description update", movie.slug) - return - - try: - en_description = await provider.get_title_description( - movie_db.title, movie_db.year + async with AsyncSessionLocal() as db: + movie = await crud.get_movie_by_slug( + db, + movie_slug, ) - except Exception as e: - logger.exception( - "Failed to fetch description for movie <%s>: %s", movie.slug, e - ) - return - try: - ru_description = await translator.translate(en_description) - except Exception as e: - logger.exception( - "Failed to translate description for movie <%s>: %s", movie.slug, e + user_result = await db.execute(select(UserModel).where(UserModel.id == user_id)) + user = user_result.scalars().first() + + if user is None: + logger.warning("User <%s> not found", user_id) + return + + if movie is None: + logger.warning( + "Movie <%s> not found", + movie_slug, + ) + return + + if not movie.year: + logger.info( + "Movie <%s> has no year, skipping metadata update", + movie_slug, + ) + return + + try: + title_data = await provider.get_title( + movie.title, + movie.year, + ) + except Exception: + logger.exception( + "Failed to fetch imdb data for movie <%s>", + movie_slug, + ) + return + + update_data: dict = {} + + rating = title_data.get("rating", {}) + metacritic = title_data.get("metacritic", {}) + + update_data["imdb_rating"] = rating.get("aggregateRating") + update_data["metacritic_score"] = metacritic.get("score") + + if not movie.description: + description = title_data.get("plot") + + if description: + try: + update_data["description"] = await translator.translate(description) + except Exception: + logger.exception( + "Failed to translate description for movie <%s>", + movie_slug, + ) + + if not update_data: + return + + await partial_update_movie( + db, + movie_slug, + MovieUpdate(**update_data), + user, ) - return - - await partial_update_movie( - db, - movie.slug, - MovieUpdate(description=ru_description), - user, - ) - logger.info("Movie <%s> description updated in background", movie.slug) + logger.info( + "Movie <%s> metadata updated", + movie_slug, + ) diff --git a/backend/tests/test_servicies/test_imdb_api/test_provider.py b/backend/tests/test_servicies/test_imdb_api/test_provider.py index 3af89f9..f1e846a 100644 --- a/backend/tests/test_servicies/test_imdb_api/test_provider.py +++ b/backend/tests/test_servicies/test_imdb_api/test_provider.py @@ -35,7 +35,7 @@ async def test_get_title_success(monkeypatch): result = await imdb.get_title("Interstellar", 2014) - mock_get_id.assert_awaited_once_with("Interstellar", 2014) + mock_get_id.assert_awaited_once_with(title="Interstellar", year=2014) mock_request.assert_awaited_once_with( HTTPMethod.GET, endpoint="titles/tt0816692", @@ -45,7 +45,7 @@ async def test_get_title_success(monkeypatch): @pytest.mark.asyncio -async def test_get_title_rating_success(monkeypatch): +async def test_get_title_with_rating_and_metacritic(monkeypatch): imdb = IMDBProvider(base_url="https://mocked-api.com") mock_title_data = { @@ -53,45 +53,38 @@ async def test_get_title_rating_success(monkeypatch): "metacritic": {"score": 74}, } - mock_get_title = AsyncMock(return_value=mock_title_data) - monkeypatch.setattr(imdb, "get_title", mock_get_title) + monkeypatch.setattr(imdb, "get_title_id", AsyncMock(return_value="tt0816692")) + monkeypatch.setattr(imdb, "_request", AsyncMock(return_value=mock_title_data)) - imdb_rating, metacritic_score = await imdb.get_title_rating("Interstellar", 2014) + result = await imdb.get_title("Interstellar", 2014) - mock_get_title.assert_awaited_once_with("Interstellar", 2014) - assert imdb_rating == 8.6 - assert metacritic_score == 74 + assert result["rating"]["aggregateRating"] == 8.6 + assert result["metacritic"]["score"] == 74 @pytest.mark.asyncio -async def test_get_title_rating_without_metacritic(monkeypatch): +async def test_get_title_without_metacritic(monkeypatch): imdb = IMDBProvider(base_url="https://mocked-api.com") mock_title_data = {"rating": {"aggregateRating": 7.1}} - monkeypatch.setattr( - imdb, - "get_title", - AsyncMock(return_value=mock_title_data), - ) + monkeypatch.setattr(imdb, "get_title_id", AsyncMock(return_value="tt0317219")) + monkeypatch.setattr(imdb, "_request", AsyncMock(return_value=mock_title_data)) - imdb_rating, metacritic_score = await imdb.get_title_rating("Cars", 2006) + result = await imdb.get_title("Cars", 2006) - assert imdb_rating == 7.1 - assert metacritic_score is None + assert result["rating"]["aggregateRating"] == 7.1 + assert result.get("metacritic") is None @pytest.mark.asyncio -async def test_get_title_rating_empty_data(monkeypatch): +async def test_get_title_empty_data(monkeypatch): imdb = IMDBProvider(base_url="https://mocked-api.com") - monkeypatch.setattr( - imdb, - "get_title", - AsyncMock(return_value={}), - ) + monkeypatch.setattr(imdb, "get_title_id", AsyncMock(return_value="tt0000000")) + monkeypatch.setattr(imdb, "_request", AsyncMock(return_value={})) - imdb_rating, metacritic_score = await imdb.get_title_rating("Unknown", 0000) + result = await imdb.get_title("Unknown", 0) - assert imdb_rating is None - assert metacritic_score is None + assert result.get("rating") is None + assert result.get("metacritic") is None