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
11 changes: 4 additions & 7 deletions app/api/endpoints/catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@ async def get_catalog(
# Supported IDs now include dynamic themes and item-based rows
if (
id != "watchly.rec"
and not id.startswith("tt")
and not id.startswith("watchly.theme.")
and not id.startswith("watchly.item.")
and not any(id.startswith(p) for p in ("tt", "watchly.theme.", "watchly.item.", "watchly.loved.", "watchly.watched."))
):
logger.warning(f"Invalid id: {id}")
raise HTTPException(
Expand Down Expand Up @@ -72,9 +70,9 @@ async def get_catalog(
recommendations = await recommendation_service.get_recommendations_for_item(item_id=id)
logger.info(f"Found {len(recommendations)} recommendations for {id}")

elif id.startswith("watchly.item."):
elif id.startswith("watchly.item.") or id.startswith("watchly.loved.") or id.startswith("watchly.watched."):
# Extract actual item ID (tt... or tmdb:...)
item_id = id.replace("watchly.item.", "")
item_id = re.sub(r"^watchly\.(item|loved|watched)\.", "", id)
recommendations = await recommendation_service.get_recommendations_for_item(item_id=item_id)
logger.info(f"Found {len(recommendations)} recommendations for item {item_id}")

Expand Down Expand Up @@ -107,8 +105,7 @@ async def get_catalog(


@router.get("/{token}/catalog/update")
@router.get("/{settings_str}/{token}/catalog/update")
async def update_catalogs(token: str, settings_str: str | None = None):
async def update_catalogs(token: str):
"""
Update the catalogs for the addon. This is a manual endpoint to update the catalogs.
"""
Expand Down
4 changes: 4 additions & 0 deletions app/api/endpoints/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ 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"):
Expand Down
3 changes: 2 additions & 1 deletion app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ def get_default_settings() -> UserSettings:
language="en-US",
catalogs=[
CatalogConfig(id="watchly.rec", name="Recommended", enabled=True),
CatalogConfig(id="watchly.item", name="Because you Loved/Watched", enabled=True),
CatalogConfig(id="watchly.loved", name="More like what you loved", enabled=True),
CatalogConfig(id="watchly.watched", name="Because you watched", enabled=True),
CatalogConfig(id="watchly.theme", name="Because of Genre/Theme", enabled=True),
],
)
2 changes: 1 addition & 1 deletion app/core/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.0-rc.4"
__version__ = "1.0.0-rc.5"
3 changes: 3 additions & 0 deletions app/models/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,6 @@ def get_top_crew(self, limit: int = 2) -> list[tuple[int, float]]:

def get_top_countries(self, limit: int = 2) -> list[tuple[str, float]]:
return self.countries.get_top_features(limit)

def get_top_year(self, limit: int = 1) -> list[tuple[int, float]]:
return self.years.get_top_features(limit)
94 changes: 54 additions & 40 deletions app/services/catalog.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from app.core.settings import UserSettings
from app.core.settings import CatalogConfig, UserSettings
from app.services.row_generator import RowGeneratorService
from app.services.scoring import ScoringService
from app.services.stremio_service import StremioService
Expand Down Expand Up @@ -26,7 +26,7 @@ def normalize_type(type_):
def build_catalog_entry(self, item, label, config_id):
item_id = item.get("_id", "")
# Use watchly.{config_id}.{item_id} format for better organization
if config_id == "watchly.item":
if config_id in ["watchly.item", "watchly.loved", "watchly.watched"]:
# New Item-based catalog format
catalog_id = f"{config_id}.{item_id}"
elif item_id.startswith("tt") and config_id in ["watchly.loved", "watchly.watched"]:
Expand Down Expand Up @@ -103,35 +103,38 @@ async def get_dynamic_catalogs(
"""
Generate all dynamic catalog rows.
"""
lang = user_settings.language if user_settings else "en-US"

include_item_based_rows = bool(
next((c for c in user_settings.catalogs if c.id == "watchly.item" and c.enabled), True)
)
include_theme_based_rows = bool(
next((c for c in user_settings.catalogs if c.id == "watchly.theme" and c.enabled), True)
)
catalogs = []
lang = user_settings.language if user_settings else "en-US"

if include_theme_based_rows:
# Theme Based
theme_config = next((c for c in user_settings.catalogs if c.id == "watchly.theme"), None)
if not theme_config or theme_config.enabled:
catalogs.extend(await self.get_theme_based_catalogs(library_items, user_settings))

# 3. Add Item-Based Rows
if include_item_based_rows:
# For Movies
await self._add_item_based_rows(catalogs, library_items, "movie", lang)
# For Series
await self._add_item_based_rows(catalogs, library_items, "series", lang)
# Item Based (Loved/Watched)
loved_config = next((c for c in user_settings.catalogs if c.id == "watchly.loved"), None)
watched_config = next((c for c in user_settings.catalogs if c.id == "watchly.watched"), None)

# Fallback for old settings (watchly.item)
if not loved_config and not watched_config:
old_config = next((c for c in user_settings.catalogs if c.id == "watchly.item"), None)
if old_config and old_config.enabled:
# Create temporary configs
loved_config = CatalogConfig(id="watchly.loved", name=None, enabled=True)
watched_config = CatalogConfig(id="watchly.watched", name=None, enabled=True)

# Movies
await self._add_item_based_rows(catalogs, library_items, "movie", lang, loved_config, watched_config)
# Series
await self._add_item_based_rows(catalogs, library_items, "series", lang, loved_config, watched_config)

return catalogs

async def _add_item_based_rows(self, catalogs: list, library_items: dict, content_type: str, language: str):
async def _add_item_based_rows(
self, catalogs: list, library_items: dict, content_type: str, language: str, loved_config, watched_config
):
"""Helper to add 'Because you watched' and 'More like' rows."""

# Translate labels
label_more_like = await translation_service.translate("More like", language)
label_bc_watched = await translation_service.translate("Because you watched", language)

# Helper to parse date
def get_date(item):
import datetime
Expand All @@ -154,24 +157,35 @@ def get_date(item):
return datetime.datetime.min.replace(tzinfo=datetime.UTC)

# 1. More Like <Loved Item>
loved = [i for i in library_items.get("loved", []) if i.get("type") == content_type]
loved.sort(key=get_date, reverse=True)
last_loved = None # Initialize for the watched check
if loved_config and loved_config.enabled:
loved = [i for i in library_items.get("loved", []) if i.get("type") == content_type]
loved.sort(key=get_date, reverse=True)

last_loved = loved[0] if loved else None
if last_loved:
label = loved_config.name
if not label or label == "More like what you loved": # Default

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The default catalog name "More like what you loved" is hardcoded here and in other files (e.g., app/core/settings.py, app/static/script.js). To improve maintainability and ensure consistency, it's better to define this string as a constant in a central location (like a new app/core/constants.py file) and import it where needed.

Suggested change
if not label or label == "More like what you loved": # Default
if not label or label == DEFAULT_LOVED_CATALOG_NAME: # Default

label = await translation_service.translate("More like what you loved", language)

last_loved = loved[0] if loved else None
if last_loved:
catalogs.append(self.build_catalog_entry(last_loved, label_more_like, "watchly.item"))
catalogs.append(self.build_catalog_entry(last_loved, label, "watchly.loved"))

# 2. Because you watched <Watched Item>
watched = [i for i in library_items.get("watched", []) if i.get("type") == content_type]
watched.sort(key=get_date, reverse=True)

last_watched = None
for item in watched:
# Avoid duplicate row if it's the same item as 'More like'
if last_loved and item.get("_id") == last_loved.get("_id"):
continue
last_watched = item
break

if last_watched:
catalogs.append(self.build_catalog_entry(last_watched, label_bc_watched, "watchly.item"))
if watched_config and watched_config.enabled:
watched = [i for i in library_items.get("watched", []) if i.get("type") == content_type]
watched.sort(key=get_date, reverse=True)

last_watched = None
for item in watched:
# Avoid duplicate row if it's the same item as 'More like'
if last_loved and item.get("_id") == last_loved.get("_id"):
continue
last_watched = item
break

if last_watched:
label = watched_config.name
if not label or label == "Because you watched":

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the 'loved' catalog, the default name "Because you watched" is hardcoded. This should also be defined as a constant in a central location to avoid inconsistencies and make future updates easier.

Suggested change
if not label or label == "Because you watched":
if not label or label == DEFAULT_WATCHED_CATALOG_NAME:

label = await translation_service.translate("Because you watched", language)

catalogs.append(self.build_catalog_entry(last_watched, label, "watchly.watched"))
6 changes: 5 additions & 1 deletion app/services/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,12 @@ async def discover_recommendations(
# query 6: Top year
if top_year:
year = top_year[0][0]
# we store year in 10 years bucket
start_year = f"{year}-01-01"
end_year = f"{int(year) + 10}-12-31"
params_rating = {
"year": year,
"primary_release_date.gte": start_year,
"primary_release_date.lte": end_year,
"sort_by": "ratings.desc",
"vote_count.gte": 500,
**base_params,
Expand Down
7 changes: 4 additions & 3 deletions app/static/script.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Default catalog configurations
const defaultCatalogs = [
{ id: 'watchly.rec', name: 'Top Picks for You', enabled: true, description: 'Personalized recommendations based on your library' },
{ id: 'watchly.item', name: 'Because you Loved/Watched', enabled: true, description: 'Recommendations based on content you interacted with' },
{ id: 'watchly.theme', name: 'Keyword Genre Based Dynamic Recommendations', enabled: true, description: 'Recommendations based on your favorite genres and themes' },
{ id: 'watchly.loved', name: 'More like what you loved', enabled: true, description: 'Recommendations similar to content you explicitly loved' },
{ id: 'watchly.watched', name: 'Because you watched', enabled: true, description: 'Recommendations based on your recent watch history' },
{ id: 'watchly.theme', name: 'Genre & Theme Collections', enabled: true, description: 'Dynamic collections based on your favorite genres' },
];

let catalogs = JSON.parse(JSON.stringify(defaultCatalogs));
Expand Down Expand Up @@ -578,7 +579,7 @@ function createCatalogItem(cat, index) {
item.className = `catalog-item group bg-slate-900 border border-slate-700 rounded-xl p-4 transition-all hover:border-slate-600 ${disabledClass}`;
item.setAttribute('data-index', index);

const isRenamable = cat.id === 'watchly.rec';
const isRenamable = true;
item.innerHTML = `
<div class="flex items-start gap-3 sm:items-center sm:gap-4">
<div class="sort-buttons flex flex-col gap-1 flex-shrink-0 mt-0.5 sm:mt-0">
Expand Down
Loading