Skip to content

Commit 4b3a26b

Browse files
feat: add translation service to translate catalog names to user language (#23)
1 parent d51287e commit 4b3a26b

File tree

12 files changed

+520
-245
lines changed

12 files changed

+520
-245
lines changed

Dockerfile

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ COPY requirements.txt .
2121
# Install Python dependencies
2222
RUN pip install --no-cache-dir -r requirements.txt
2323

24-
# Copy application code (including static files)
2524
COPY app/ ./app/
26-
COPY static/ ./static/
2725
COPY main.py .
2826
COPY pyproject.toml .
2927

app/api/endpoints/manifest.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from app.core.version import __version__
88
from app.services.catalog import DynamicCatalogService
99
from app.services.stremio_service import StremioService
10+
from app.services.translation import translation_service
1011
from app.utils import resolve_user_credentials
1112

1213
router = APIRouter()
@@ -45,7 +46,7 @@ def get_base_manifest(user_settings: UserSettings | None = None):
4546
"version": __version__,
4647
"name": settings.ADDON_NAME,
4748
"description": "Movie and series recommendations based on your Stremio library",
48-
"logo": "https://raw.githubusercontent.com/TimilsinaBimal/Watchly/refs/heads/main/static/logo.png",
49+
"logo": "https://raw.githubusercontent.com/TimilsinaBimal/Watchly/refs/heads/main/app/static/logo.png",
4950
"resources": [{"name": "catalog", "types": ["movie", "series"], "idPrefixes": ["tt"]}],
5051
"types": ["movie", "series"],
5152
"idPrefixes": ["tt"],
@@ -106,6 +107,11 @@ async def _manifest_handler(response: Response, token: str | None, settings_str:
106107

107108
base_manifest = get_base_manifest(user_settings)
108109

110+
if user_settings and user_settings.language:
111+
for cat in base_manifest.get("catalogs", []):
112+
if cat.get("name"):
113+
cat["name"] = await translation_service.translate(cat["name"], user_settings.language)
114+
109115
if token:
110116
# We pass settings_str to fetch_catalogs so it can cache different versions
111117
# We COPY the lists to avoid modifying cached objects or base_manifest defaults

app/core/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,10 @@ async def lifespan(app: FastAPI):
7474
# Static directory is at project root (3 levels up from app/core/app.py)
7575
# app/core/app.py -> app/core -> app -> root
7676
project_root = Path(__file__).resolve().parent.parent.parent
77-
static_dir = project_root / "static"
77+
static_dir = project_root / "app/static"
7878

7979
if static_dir.exists():
80-
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
80+
app.mount("/app/static", StaticFiles(directory=str(static_dir)), name="static")
8181

8282

8383
# Serve index.html at /configure and /{token}/configure

app/services/catalog.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from app.services.scoring import ScoringService
44
from app.services.stremio_service import StremioService
55
from app.services.tmdb_service import TMDBService
6+
from app.services.translation import translation_service
67
from app.services.user_profile import UserProfileService
78

89

@@ -34,9 +35,6 @@ def build_catalog_entry(self, item, label, config_id):
3435
catalog_id = item_id
3536

3637
name = item.get("name")
37-
# # Truncate long names for cleaner UI
38-
# if len(name) > 25:
39-
# name = name[:25] + "..."
4038

4139
return {
4240
"type": self.normalize_type(item.get("type")),
@@ -49,6 +47,8 @@ async def get_theme_based_catalogs(
4947
self, library_items: list[dict], user_settings: UserSettings | None = None
5048
) -> list[dict]:
5149
catalogs = []
50+
lang = user_settings.language if user_settings else "en-US"
51+
5252
# 1. Build User Profile
5353
# Combine loved and watched
5454
all_items = library_items.get("loved", []) + library_items.get("watched", [])
@@ -82,7 +82,8 @@ async def get_theme_based_catalogs(
8282
movie_rows = await self.row_generator.generate_rows(movie_profile, "movie")
8383

8484
for row in movie_rows:
85-
catalogs.append({"type": "movie", "id": row.id, "name": row.title, "extra": []})
85+
translated_title = await translation_service.translate(row.title, lang)
86+
catalogs.append({"type": "movie", "id": row.id, "name": translated_title, "extra": []})
8687

8788
# Generate for Series
8889
series_profile = await self.user_profile_service.build_user_profile(
@@ -91,7 +92,8 @@ async def get_theme_based_catalogs(
9192
series_rows = await self.row_generator.generate_rows(series_profile, "series")
9293

9394
for row in series_rows:
94-
catalogs.append({"type": "series", "id": row.id, "name": row.title, "extra": []})
95+
translated_title = await translation_service.translate(row.title, lang)
96+
catalogs.append({"type": "series", "id": row.id, "name": translated_title, "extra": []})
9597

9698
return catalogs
9799

@@ -101,6 +103,7 @@ async def get_dynamic_catalogs(
101103
"""
102104
Generate all dynamic catalog rows.
103105
"""
106+
lang = user_settings.language if user_settings else "en-US"
104107

105108
include_item_based_rows = bool(
106109
next((c for c in user_settings.catalogs if c.id == "watchly.item" and c.enabled), True)
@@ -116,15 +119,19 @@ async def get_dynamic_catalogs(
116119
# 3. Add Item-Based Rows
117120
if include_item_based_rows:
118121
# For Movies
119-
self._add_item_based_rows(catalogs, library_items, "movie")
122+
await self._add_item_based_rows(catalogs, library_items, "movie", lang)
120123
# For Series
121-
self._add_item_based_rows(catalogs, library_items, "series")
124+
await self._add_item_based_rows(catalogs, library_items, "series", lang)
122125

123126
return catalogs
124127

125-
def _add_item_based_rows(self, catalogs: list, library_items: dict, content_type: str):
128+
async def _add_item_based_rows(self, catalogs: list, library_items: dict, content_type: str, language: str):
126129
"""Helper to add 'Because you watched' and 'More like' rows."""
127130

131+
# Translate labels
132+
label_more_like = await translation_service.translate("More like", language)
133+
label_bc_watched = await translation_service.translate("Because you watched", language)
134+
128135
# Helper to parse date
129136
def get_date(item):
130137
import datetime
@@ -152,13 +159,9 @@ def get_date(item):
152159

153160
last_loved = loved[0] if loved else None
154161
if last_loved:
155-
catalogs.append(self.build_catalog_entry(last_loved, "More like", "watchly.item"))
162+
catalogs.append(self.build_catalog_entry(last_loved, label_more_like, "watchly.item"))
156163

157164
# 2. Because you watched <Watched Item>
158-
# Filter only watched items (exclude loved if possible or treat as separate pool)
159-
# Actually, watched_items in StremioService include everything with progress or watched flag
160-
# We want 'Because you watched' to reflect recent activity.
161-
162165
watched = [i for i in library_items.get("watched", []) if i.get("type") == content_type]
163166
watched.sort(key=get_date, reverse=True)
164167

@@ -170,8 +173,5 @@ def get_date(item):
170173
last_watched = item
171174
break
172175

173-
# If no distinct last watched found (e.g. only watched 1 item and it was loved),
174-
# we can skip or pick the next best.
175-
176176
if last_watched:
177-
catalogs.append(self.build_catalog_entry(last_watched, "Because you watched", "watchly.item"))
177+
catalogs.append(self.build_catalog_entry(last_watched, label_bc_watched, "watchly.item"))

app/services/translation.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import asyncio
2+
3+
from async_lru import alru_cache
4+
from deep_translator import GoogleTranslator
5+
from loguru import logger
6+
7+
8+
class TranslationService:
9+
@alru_cache(maxsize=1000, ttl=7 * 24 * 60 * 60)
10+
async def translate(self, text: str, target_lang: str | None) -> str:
11+
if not text or not target_lang:
12+
return text
13+
14+
# Normalize lang (e.g. en-US -> en)
15+
lang = target_lang.split("-")[0].lower()
16+
if lang == "en":
17+
return text
18+
19+
try:
20+
loop = asyncio.get_running_loop()
21+
22+
translated = await loop.run_in_executor(
23+
None, lambda: GoogleTranslator(source="auto", target=lang).translate(text)
24+
)
25+
return translated if translated else text
26+
except Exception as e:
27+
logger.warning(f"Translation failed for '{text}' to '{lang}': {e}")
28+
return text
29+
30+
31+
translation_service = TranslationService()

0 commit comments

Comments
 (0)