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
8 changes: 5 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ PORT=8000
ADDON_ID=com.bimal.watchly
ADDON_NAME=Watchly
REDIS_URL=redis://redis:6379/0
TOKEN_SALT=replace-with-a-long-random-string
TOKEN_TTL_SECONDS=0
ANNOUNCEMENT_HTML=
HOST_NAME=<your_addon_url>
RECOMMENDATION_SOURCE_ITEMS_LIMIT=10 # fetches recent watched/loved 10 movies and series to recommend based on those

TOKEN_SALT=change-me
# generate some very long random string preferrably using cryptography libraries
# UPDATER
CATALOG_UPDATE_MODE=cron
CATALOG_UPDATE_MODE=cron # Available options: cron, interval
# cron updates catalogs at specified times
# interval updates in specific intervals
CATALOG_UPDATE_CRON_SCHEDULES=[{"hour": 12, "minute": 0, "id": "catalog_refresh_noon"},{"hour": 0, "minute": 0, "id": "catalog_refresh_midnight"}]
CATALOG_REFRESH_INTERVAL_SECONDS=6*60*60
56 changes: 22 additions & 34 deletions app/api/endpoints/catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,27 @@
from fastapi import APIRouter, HTTPException, Response
from loguru import logger

from app.core.settings import decode_settings
from app.core.security import redact_token
from app.core.settings import UserSettings, get_default_settings
from app.services.catalog_updater import refresh_catalogs_for_credentials
from app.services.recommendation_service import RecommendationService
from app.services.stremio_service import StremioService
from app.utils import redact_token, resolve_user_credentials
from app.services.token_store import token_store

MAX_RESULTS = 50
SOURCE_ITEMS_LIMIT = 10

router = APIRouter()


@router.get("/catalog/{type}/{id}.json")
@router.get("/{token}/catalog/{type}/{id}.json")
@router.get("/{settings_str}/{token}/catalog/{type}/{id}.json")
async def get_catalog(
type: str,
id: str,
response: Response,
token: str | None = None,
settings_str: str | None = None,
):
"""
Stremio catalog endpoint for movies and series.
"""
async def get_catalog(type: str, id: str, response: Response, token: str):
if not token:
raise HTTPException(
status_code=400,
detail="Missing credentials token. Please open Watchly from a configured manifest URL.",
)

logger.info(f"[{redact_token(token)}] Fetching catalog for {type} with id {id}")

credentials = await resolve_user_credentials(token)

if type not in ["movie", "series"]:
logger.warning(f"Invalid type: {type}")
raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'")
Expand All @@ -51,22 +40,25 @@ async def get_catalog(
" specific item IDs."
),
)

logger.info(f"[{redact_token(token)}] Fetching catalog for {type} with id {id}")

credentials = await token_store.get_user_data(token)
if not credentials:
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
try:
# Decode settings to get language
user_settings = decode_settings(settings_str) if settings_str else None
# Extract settings from credentials
settings_dict = credentials.get("settings", {})
user_settings = UserSettings(**settings_dict) if settings_dict else get_default_settings()
language = user_settings.language if user_settings else "en-US"

# Create services with credentials
stremio_service = StremioService(
username=credentials.get("username") or "",
password=credentials.get("password") or "",
auth_key=credentials.get("authKey"),
)
stremio_service = StremioService(auth_key=credentials.get("authKey"))
recommendation_service = RecommendationService(
stremio_service=stremio_service, language=language, user_settings=user_settings
)

# Handle item-based recommendations (legacy or explicit link)
# Handle item-based recommendations
if id.startswith("tt"):
recommendations = await recommendation_service.get_recommendations_for_item(item_id=id)
logger.info(f"Found {len(recommendations)} recommendations for {id}")
Expand All @@ -84,12 +76,8 @@ async def get_catalog(
logger.info(f"Found {len(recommendations)} recommendations for theme {id}")

else:
# Top Picks (watchly.rec)

recommendations = await recommendation_service.get_recommendations(
content_type=type,
source_items_limit=10,
max_results=50,
content_type=type, source_items_limit=SOURCE_ITEMS_LIMIT, max_results=MAX_RESULTS
)
logger.info(f"Found {len(recommendations)} recommendations for {type}")

Expand All @@ -101,7 +89,7 @@ async def get_catalog(
except HTTPException:
raise
except Exception as e:
logger.error(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}", exc_info=True)
logger.exception(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}")
raise HTTPException(status_code=500, detail=str(e))


Expand All @@ -111,9 +99,9 @@ async def update_catalogs(token: str):
Update the catalogs for the addon. This is a manual endpoint to update the catalogs.
"""
# Decode credentials from path
credentials = await resolve_user_credentials(token)
credentials = await token_store.get_user_data(token)

logger.info(f"[{redact_token(token)}] Updating catalogs in response to manual request")
updated = await refresh_catalogs_for_credentials(credentials)
updated = await refresh_catalogs_for_credentials(token, credentials)
logger.info(f"Manual catalog update completed: {updated}")
return {"success": updated}
117 changes: 45 additions & 72 deletions app/api/endpoints/manifest.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
from async_lru import alru_cache
from fastapi import Response
from fastapi import HTTPException, Response
from fastapi.routing import APIRouter

from app.core.config import settings
from app.core.settings import UserSettings, decode_settings
from app.core.settings import UserSettings, get_default_settings
from app.core.version import __version__
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
from app.utils import resolve_user_credentials

router = APIRouter()

Expand Down Expand Up @@ -55,27 +55,19 @@ def get_base_manifest(user_settings: UserSettings | None = None):
}


# Cache catalog definitions for 1 hour (3600s)
# Cache catalog definitions for 1 hour (3600s)
@alru_cache(maxsize=1000, ttl=3600)
async def fetch_catalogs(token: str | None = None, settings_str: str | None = None):
if not token:
return []
async def fetch_catalogs(token: str):
credentials = await token_store.get_user_data(token)
if not credentials:
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")

credentials = await resolve_user_credentials(token)

if settings_str:
user_settings = decode_settings(settings_str)
elif credentials.get("settings"):
if credentials.get("settings"):
user_settings = UserSettings(**credentials["settings"])
else:
user_settings = None
user_settings = get_default_settings()

stremio_service = StremioService(
username=credentials.get("username") or "",
password=credentials.get("password") or "",
auth_key=credentials.get("authKey"),
)
stremio_service = StremioService(auth_key=credentials.get("authKey"))

# Note: get_library_items is expensive, but we need it to determine *which* genre catalogs to show.
library_items = await stremio_service.get_library_items()
Expand All @@ -88,75 +80,56 @@ async def fetch_catalogs(token: str | None = None, settings_str: str | None = No
return catalogs


async def _manifest_handler(response: Response, token: str | None, settings_str: str | None):
"""Stremio manifest handler."""
# Cache manifest for 1 day (86400 seconds)
def get_config_id(catalog) -> str | None:
catalog_id = catalog.get("id", "")
if catalog_id.startswith("watchly.theme."):
return "watchly.theme"
if catalog_id.startswith("watchly.loved."):
return "watchly.loved"
if catalog_id.startswith("watchly.watched."):
return "watchly.watched"
if catalog_id.startswith("watchly.item."):
return "watchly.item"
if catalog_id.startswith("watchly.rec"):
return "watchly.rec"
return catalog_id


async def _manifest_handler(response: Response, token: str):
response.headers["Cache-Control"] = "public, max-age=86400"

if not token:
raise HTTPException(status_code=401, detail="Missing token. Please reconfigure the addon.")

user_settings = None
if settings_str:
user_settings = decode_settings(settings_str)
elif token:
try:
creds = await resolve_user_credentials(token)
if creds.get("settings"):
user_settings = UserSettings(**creds["settings"])
except Exception:
# Fallback to defaults if token resolution fails (or let it fail later in fetch_catalogs)
pass
try:
creds = await token_store.get_user_data(token)
if creds.get("settings"):
user_settings = UserSettings(**creds["settings"])
except Exception:
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")

base_manifest = get_base_manifest(user_settings)

# translate to target language
if user_settings and user_settings.language:
for cat in base_manifest.get("catalogs", []):
if cat.get("name"):
cat["name"] = await translation_service.translate(cat["name"], user_settings.language)

if token:
# We pass settings_str to fetch_catalogs so it can cache different versions
# We COPY the lists to avoid modifying cached objects or base_manifest defaults
fetched_catalogs = await fetch_catalogs(token, settings_str)

# Create a new list with copies of all catalogs
all_catalogs = [c.copy() for c in base_manifest["catalogs"]] + [c.copy() for c in fetched_catalogs]

if user_settings:
# Create a lookup for order index
order_map = {c.id: i for i, c in enumerate(user_settings.catalogs)}

# Sort. Items not in map go to end.
# Extract config id from catalog id for matching with user settings
def get_config_id(catalog):
catalog_id = catalog.get("id", "")
if catalog_id.startswith("watchly.theme."):
return "watchly.theme"
if catalog_id.startswith("watchly.loved."):
return "watchly.loved"
if catalog_id.startswith("watchly.watched."):
return "watchly.watched"
if catalog_id.startswith("watchly.item."):
return "watchly.item"
if catalog_id.startswith("watchly.rec"):
return "watchly.rec"
return catalog_id

all_catalogs.sort(key=lambda x: order_map.get(get_config_id(x), 999))

base_manifest["catalogs"] = all_catalogs
fetched_catalogs = await fetch_catalogs(token)

return base_manifest
all_catalogs = [c.copy() for c in base_manifest["catalogs"]] + [c.copy() for c in fetched_catalogs]

if user_settings:
order_map = {c.id: i for i, c in enumerate(user_settings.catalogs)}
all_catalogs.sort(key=lambda x: order_map.get(get_config_id(x), 999))

base_manifest["catalogs"] = all_catalogs

@router.get("/manifest.json")
async def manifest_root(response: Response):
return await _manifest_handler(response, None, None)
return base_manifest


@router.get("/{token}/manifest.json")
async def manifest_token(response: Response, token: str):
return await _manifest_handler(response, token, None)


@router.get("/{settings_str}/{token}/manifest.json")
async def manifest_settings(response: Response, settings_str: str, token: str):
return await _manifest_handler(response, token, settings_str)
return await _manifest_handler(response, token)
Loading