diff --git a/app/api/endpoints/catalogs.py b/app/api/endpoints/catalogs.py index 729f1d3..352cf08 100644 --- a/app/api/endpoints/catalogs.py +++ b/app/api/endpoints/catalogs.py @@ -83,7 +83,7 @@ async def get_catalog(type: str, id: str, response: Response, token: str): logger.info(f"Returning {len(recommendations)} items for {type}") # Cache catalog responses for 4 hours - response.headers["Cache-Control"] = "public, max-age=14400" + response.headers["Cache-Control"] = "public, max-age=14400" if len(recommendations) > 0 else "no-cache" return {"metas": recommendations} except HTTPException: diff --git a/app/core/config.py b/app/core/config.py index 6cacbfa..7996a39 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -36,6 +36,10 @@ class Settings(BaseSettings): RECOMMENDATION_SOURCE_ITEMS_LIMIT: int = 10 + # AI + DEFAULT_GEMINI_MODEL: str = "gemma-3-27b-it" + GEMINI_API_KEY: str | None = None + settings = Settings() diff --git a/app/services/catalog_updater.py b/app/services/catalog_updater.py index 7c6a3ca..2400195 100644 --- a/app/services/catalog_updater.py +++ b/app/services/catalog_updater.py @@ -13,6 +13,7 @@ from app.services.catalog import DynamicCatalogService from app.services.stremio_service import StremioService from app.services.token_store import token_store +from app.services.translation import translation_service # Max number of concurrent updates to prevent overwhelming external APIs MAX_CONCURRENT_UPDATES = 5 @@ -50,6 +51,11 @@ async def refresh_catalogs_for_credentials(token: str, credentials: dict[str, An catalogs = await dynamic_catalog_service.get_dynamic_catalogs( library_items=library_items, user_settings=user_settings ) + + if user_settings and user_settings.language: + for cat in catalogs: + if name := cat.get("name"): + cat["name"] = await translation_service.translate(name, user_settings.language) logger.info(f"[{redact_token(token)}] Prepared {len(catalogs)} catalogs") return await stremio_service.update_catalogs(catalogs, auth_key) except Exception as e: diff --git a/app/services/gemini.py b/app/services/gemini.py new file mode 100644 index 0000000..079f7c3 --- /dev/null +++ b/app/services/gemini.py @@ -0,0 +1,56 @@ +from google import genai +from loguru import logger + +from app.core.config import settings + + +class GeminiService: + def __init__(self, model: str = settings.DEFAULT_GEMINI_MODEL): + self.model = model + self.client = None + if api_key := settings.GEMINI_API_KEY: + try: + self.client = genai.Client(api_key=api_key) + except Exception as e: + logger.warning(f"Failed to initialize Gemini client: {e}") + else: + logger.warning("GEMINI_API_KEY not set. Gemini features will be disabled.") + + @staticmethod + def get_prompt(): + return """ + You are a content catalog naming expert. + Given filters like genre, keywords, countries, or years, generate natural, + engaging catalog row titles that streaming platforms would use. + + Examples: + - Genre: Action, Country: South Korea → "Korean Action Thrillers" + - Keyword: "space", Genre: Sci-Fi → "Space Exploration Adventures" + - Genre: Drama, Country: France → "Acclaimed French Cinema" + - Country: "USA" + Genre: "Sci-Fi and Fantasy" → "Hollywood Sci-Fi and Fantasy" + - Keywords: "revenge" + "martial arts" → "Revenge & Martial Arts" + + Keep titles: + - Short (2-5 words) + - Natural and engaging + - Focused on what makes the content appealing + - Only return a single best title and nothing else. + """ + + def generate_content(self, prompt: str) -> str: + system_prompt = self.get_prompt() + if not self.client: + logger.warning("Gemini client not initialized. Gemini features will be disabled.") + return "" + try: + response = self.client.models.generate_content( + model=self.model, + contents=system_prompt + "\n\n" + prompt, + ) + return response.text.strip() + except Exception as e: + logger.error(f"Error generating content: {e}") + return "" + + +gemini_service = GeminiService() diff --git a/app/services/row_generator.py b/app/services/row_generator.py index 7676921..71cec06 100644 --- a/app/services/row_generator.py +++ b/app/services/row_generator.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from app.models.profile import UserTasteProfile +from app.services.gemini import gemini_service from app.services.tmdb.countries import COUNTRY_ADJECTIVES from app.services.tmdb.genre import movie_genres, series_genres from app.services.tmdb_service import TMDBService @@ -62,38 +63,54 @@ def get_cname(code): return random.choice(adjectives) return "" - # Strategy 1: Pure Keyword Row (Top Priority) + # Strategy 1: Combined Keyword Row (Top Priority) if top_keywords: - k_id = top_keywords[0][0] - kw_name = await self._get_keyword_name(k_id) - if kw_name: + k_id1 = top_keywords[0][0] + kw_name1 = await self._get_keyword_name(k_id1) + + use_single_keyword_row = True + if len(top_keywords) >= 2: + k_id2 = top_keywords[1][0] + kw_name2 = await self._get_keyword_name(k_id2) + title = "" + if kw_name1 and kw_name2: + title = gemini_service.generate_content(f"Keywords: {kw_name1} + {kw_name2}") + + if title: + rows.append( + RowDefinition( + title=title, + id=f"watchly.theme.k{k_id1}.k{k_id2}", + keywords=[k_id1, k_id2], + ) + ) + use_single_keyword_row = False + + if use_single_keyword_row and kw_name1: rows.append( RowDefinition( - title=f"{normalize_keyword(kw_name)}", - id=f"watchly.theme.k{k_id}", - keywords=[k_id], + title=normalize_keyword(kw_name1), + id=f"watchly.theme.k{k_id1}", + keywords=[k_id1], ) ) # Strategy 2: Keyword + Genre (Specific Niche) - if top_genres and len(top_keywords) > 1: + if top_genres and len(top_keywords) > 2: g_id = top_genres[0][0] # get random keywords: Just to surprise user in every refresh - k_id = random.choice(top_keywords[1:])[0] + k_id = random.choice(top_keywords[2:])[0] if k_id: kw_name = await self._get_keyword_name(k_id) if kw_name: - title = f"{normalize_keyword(kw_name)} {get_gname(g_id)}" - # keyword and genre can have same name sometimes, remove if so - words = title.split() - seen_words = set() - unique_words = [] - for word in words: - if word not in seen_words: - unique_words.append(word) - seen_words.add(word) - title = " ".join(unique_words) + title = gemini_service.generate_content( + f"Genre: {get_gname(g_id)} + Keyword: {normalize_keyword(kw_name)}" + ) + if not title: + title = f"{get_gname(g_id)} {normalize_keyword(kw_name)}" + # keyword and genre can have same name sometimes, remove if so + title = " ".join(dict.fromkeys(title.split())) rows.append( RowDefinition( @@ -110,9 +127,12 @@ def get_cname(code): c_code = top_countries[0][0] c_adj = get_cname(c_code) if c_adj: + title = gemini_service.generate_content(f"Genre: {get_gname(g_id)} + Country: {c_adj}") + if not title: + title = f"{get_gname(g_id)} {c_adj}" rows.append( RowDefinition( - title=f"{c_adj} {get_gname(g_id)}", + title=title, id=f"watchly.theme.g{g_id}.ct{c_code}", # ct for country genres=[g_id], country=c_code, @@ -130,9 +150,12 @@ def get_cname(code): # # Only do this if decade is valid and somewhat old (nostalgia factor) if 1970 <= decade_start <= 2010: decade_str = str(decade_start)[2:] + "s" # "90s" + title = gemini_service.generate_content(f"Genre: {get_gname(g_id)} + Era: {decade_str}") + if not title: + title = f"{get_gname(g_id)} {decade_str}" rows.append( RowDefinition( - title=f"{decade_str} {get_gname(g_id)}", + title=title, id=f"watchly.theme.g{g_id}.y{decade_start}", genres=[g_id], year_range=(decade_start, decade_start + 9), diff --git a/pyproject.toml b/pyproject.toml index a665ae3..33e904c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "cryptography>=46.0.3", "deep-translator>=1.11.4", "fastapi>=0.104.1", + "google-genai>=1.54.0", "httpx>=0.25.2", "loguru>=0.7.2", "pydantic>=2.5.0", diff --git a/requirements.txt b/requirements.txt index ec10495..913c638 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,59 +1,14 @@ -annotated-doc==0.0.4 -annotated-types==0.7.0 -anyio==4.11.0 -apscheduler==3.11.1 -async-lru==2.0.5 -async-timeout==5.0.1 -beautifulsoup4==4.14.3 -black==25.11.0 -cachetools==6.2.2 -certifi==2025.11.12 -cffi==2.0.0 -cfgv==3.5.0 -charset-normalizer==3.4.4 -click==8.3.1 -cryptography==46.0.3 -deep-translator==1.11.4 -distlib==0.4.0 -exceptiongroup==1.3.0 -fastapi==0.121.2 -filelock==3.20.0 -flake9==3.8.3.post2 -h11==0.16.0 -httpcore==1.0.9 -httptools==0.7.1 -httpx==0.28.1 -identify==2.6.15 -idna==3.11 -loguru==0.7.3 -mccabe==0.6.1 -mypy-extensions==1.1.0 -nodeenv==1.9.1 -packaging==25.0 -pathspec==0.12.1 -platformdirs==4.5.0 -pre-commit==4.4.0 -pycodestyle==2.6.0 -pycparser==2.23 -pydantic==2.12.4 -pydantic-core==2.41.5 -pydantic-settings==2.12.0 -pyflakes==2.2.0 -python-dotenv==1.2.1 -pytokens==0.3.0 -pyyaml==6.0.3 -redis==7.1.0 -requests==2.32.5 -sniffio==1.3.1 -soupsieve==2.8 -starlette==0.49.3 -tomli==2.3.0 -typing-extensions==4.15.0 -typing-inspection==0.4.2 -tzlocal==5.3.1 -urllib3==2.6.0 -uvicorn==0.38.0 -uvloop==0.22.1 -virtualenv==20.35.4 -watchfiles==1.1.1 -websockets==15.0.1 +apscheduler>=3.11.1 +async-lru>=2.0.5 +cachetools>=6.2.2 +cryptography>=46.0.3 +deep-translator>=1.11.4 +fastapi>=0.104.1 +google-genai>=1.54.0 +httpx>=0.25.2 +loguru>=0.7.2 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 +redis>=5.0.1 +tomli>=2.3.0 +uvicorn[standard]>=0.24.0 diff --git a/uv.lock b/uv.lock index c2e8310..79a7e75 100644 --- a/uv.lock +++ b/uv.lock @@ -476,6 +476,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/4e/230bc6b366dd2217e0b7681b5ace14a3fb4aec12bedb5666f33e19375cc7/flake9-3.8.3.post2-py3-none-any.whl", hash = "sha256:47dced969a802a8892740bcaa35ae07232709b2ade803c45f48dd03ccb7f825f", size = 73780, upload-time = "2020-06-19T08:19:09.437Z" }, ] +[[package]] +name = "google-auth" +version = "2.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/ef/66d14cf0e01b08d2d51ffc3c20410c4e134a1548fc246a6081eae585a4fe/google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483", size = 296359, upload-time = "2025-11-06T00:13:36.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114, upload-time = "2025-11-06T00:13:35.209Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/5d/0b8305a034db5ffcaf99d0842a0d941e01851c1c3806c68fb43723837c72/google_genai-1.54.0.tar.gz", hash = "sha256:ab7de6741437ee17f01d4db85e351eb8504466663cd83ce420ecb4e29b58b00d", size = 260467, upload-time = "2025-12-08T19:03:13.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/93/7096cdc1a4a55cc60bc02638f7077255acd32968c437cc32783e5abe430d/google_genai-1.54.0-py3-none-any.whl", hash = "sha256:c06853402814a47bb020f2dc50fc03fb77cc349dff65da35cddbd19046f9bd58", size = 262359, upload-time = "2025-12-08T19:03:12.337Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -657,6 +695,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycodestyle" version = "2.6.0" @@ -940,6 +999,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -971,6 +1042,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + [[package]] name = "tomli" version = "2.3.0" @@ -1268,6 +1348,7 @@ dependencies = [ { name = "cryptography" }, { name = "deep-translator" }, { name = "fastapi" }, + { name = "google-genai" }, { name = "httpx" }, { name = "loguru" }, { name = "pydantic" }, @@ -1292,6 +1373,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=46.0.3" }, { name = "deep-translator", specifier = ">=1.11.4" }, { name = "fastapi", specifier = ">=0.104.1" }, + { name = "google-genai", specifier = ">=1.54.0" }, { name = "httpx", specifier = ">=0.25.2" }, { name = "loguru", specifier = ">=0.7.2" }, { name = "pydantic", specifier = ">=2.5.0" },