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
6 changes: 2 additions & 4 deletions backend/backlog_app/api/view/movie_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand All @@ -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


Expand Down
179 changes: 107 additions & 72 deletions backend/backlog_app/servicies/imdb_api/provider.py
Original file line number Diff line number Diff line change
@@ -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}",
)
147 changes: 77 additions & 70 deletions backend/backlog_app/tasks/movie_task.py
Original file line number Diff line number Diff line change
@@ -1,90 +1,97 @@
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__)

provider = IMDBProvider(base_url=settings.imdb_url)
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,
)
Loading
Loading