Skip to content

Commit 39c08f4

Browse files
feat: add options to select minimum and maximum number of items in catalogs (#54)
1 parent 042f8a9 commit 39c08f4

File tree

6 files changed

+256
-15
lines changed

6 files changed

+256
-15
lines changed

app/api/endpoints/catalogs.py

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from fastapi import APIRouter, HTTPException, Response
44
from loguru import logger
55

6+
from app.api.endpoints.manifest import get_config_id
67
from app.core.security import redact_token
78
from app.core.settings import UserSettings, get_default_settings
89
from app.services.catalog_updater import refresh_catalogs_for_credentials
@@ -11,6 +12,8 @@
1112
from app.services.token_store import token_store
1213

1314
MAX_RESULTS = 50
15+
DEFAULT_MIN_ITEMS = 20
16+
DEFAULT_MAX_ITEMS = 32
1417
SOURCE_ITEMS_LIMIT = 10
1518

1619
router = APIRouter()
@@ -30,7 +33,14 @@ async def get_catalog(type: str, id: str, response: Response, token: str):
3033

3134
# Supported IDs now include dynamic themes and item-based rows
3235
if id != "watchly.rec" and not any(
33-
id.startswith(p) for p in ("tt", "watchly.theme.", "watchly.item.", "watchly.loved.", "watchly.watched.")
36+
id.startswith(p)
37+
for p in (
38+
"tt",
39+
"watchly.theme.",
40+
"watchly.item.",
41+
"watchly.loved.",
42+
"watchly.watched.",
43+
)
3444
):
3545
logger.warning(f"Invalid id: {id}")
3646
raise HTTPException(
@@ -64,32 +74,80 @@ async def get_catalog(type: str, id: str, response: Response, token: str):
6474
library_data=library_items,
6575
)
6676

77+
# Resolve per-catalog limits (min/max)
78+
def _get_limits() -> tuple[int, int]:
79+
try:
80+
cfg_id = get_config_id({"id": id})
81+
except Exception:
82+
cfg_id = id
83+
try:
84+
cfg = next((c for c in user_settings.catalogs if c.id == cfg_id), None)
85+
if cfg and hasattr(cfg, "min_items") and hasattr(cfg, "max_items"):
86+
return int(cfg.min_items or DEFAULT_MIN_ITEMS), int(cfg.max_items or DEFAULT_MAX_ITEMS)
87+
except Exception:
88+
pass
89+
return DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
90+
91+
min_items, max_items = _get_limits()
92+
# Enforce caps: min_items <= 20, max_items <= 32 and max >= min
93+
try:
94+
min_items = max(1, min(DEFAULT_MIN_ITEMS, int(min_items)))
95+
max_items = max(min_items, min(DEFAULT_MAX_ITEMS, int(max_items)))
96+
except (ValueError, TypeError):
97+
logger.warning(
98+
f"Invalid min/max items values. Falling back to defaults. min_items={min_items}, max_items={max_items}"
99+
)
100+
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
101+
67102
# Handle item-based recommendations
68103
if id.startswith("tt"):
104+
try:
105+
recommendation_service.per_item_limit = max_items
106+
except Exception:
107+
pass
69108
recommendations = await recommendation_service.get_recommendations_for_item(item_id=id)
109+
if len(recommendations) < min_items:
110+
recommendations = await recommendation_service.pad_to_min(type, recommendations, min_items)
70111
logger.info(f"Found {len(recommendations)} recommendations for {id}")
71112

72-
elif id.startswith("watchly.item.") or id.startswith("watchly.loved.") or id.startswith("watchly.watched."):
113+
elif any(
114+
id.startswith(p)
115+
for p in (
116+
"watchly.item.",
117+
"watchly.loved.",
118+
"watchly.watched.",
119+
)
120+
):
73121
# Extract actual item ID (tt... or tmdb:...)
74122
item_id = re.sub(r"^watchly\.(item|loved|watched)\.", "", id)
123+
try:
124+
recommendation_service.per_item_limit = max_items
125+
except Exception:
126+
pass
75127
recommendations = await recommendation_service.get_recommendations_for_item(item_id=item_id)
128+
if len(recommendations) < min_items:
129+
recommendations = await recommendation_service.pad_to_min(type, recommendations, min_items)
76130
logger.info(f"Found {len(recommendations)} recommendations for item {item_id}")
77131

78132
elif id.startswith("watchly.theme."):
79-
recommendations = await recommendation_service.get_recommendations_for_theme(theme_id=id, content_type=type)
133+
recommendations = await recommendation_service.get_recommendations_for_theme(
134+
theme_id=id, content_type=type, limit=max_items
135+
)
136+
if len(recommendations) < min_items:
137+
recommendations = await recommendation_service.pad_to_min(type, recommendations, min_items)
80138
logger.info(f"Found {len(recommendations)} recommendations for theme {id}")
81139

82140
else:
83141
recommendations = await recommendation_service.get_recommendations(
84-
content_type=type, source_items_limit=SOURCE_ITEMS_LIMIT, max_results=MAX_RESULTS
142+
content_type=type, source_items_limit=SOURCE_ITEMS_LIMIT, max_results=max_items
85143
)
144+
if len(recommendations) < min_items:
145+
recommendations = await recommendation_service.pad_to_min(type, recommendations, min_items)
86146
logger.info(f"Found {len(recommendations)} recommendations for {type}")
87147

88148
logger.info(f"Returning {len(recommendations)} items for {type}")
89-
# Cache catalog responses for 4 hours
90-
response.headers["Cache-Control"] = (
91-
"public, max-age=14400" if len(recommendations) > 0 else "public, max-age=7200"
92-
)
149+
# Avoid serving stale results; revalidate on each request
150+
response.headers["Cache-Control"] = "no-cache"
93151
return {"metas": recommendations}
94152

95153
except HTTPException:

app/core/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ class CatalogConfig(BaseModel):
55
id: str # "watchly.rec", "watchly.theme", "watchly.item"
66
name: str | None = None
77
enabled: bool = True
8+
min_items: int = Field(default=20, ge=1, le=20)
9+
max_items: int = Field(default=24, ge=1, le=32)
810

911

1012
class UserSettings(BaseModel):

app/core/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.1.4"
1+
__version__ = "1.2.0"

app/services/recommendation_service.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,95 @@ def _passes_top_genre(item_genre_ids: list[int] | None) -> bool:
598598
logger.info(f"Found {len(final_items)} valid recommendations for {item_id}")
599599
return final_items
600600

601+
async def pad_to_min(self, content_type: str, existing: list[dict], min_items: int) -> list[dict]:
602+
"""Pad results with trending/top-rated to satisfy a minimum count.
603+
Returns a new list with extra items appended (deduped).
604+
"""
605+
try:
606+
need = max(0, int(min_items) - len(existing))
607+
except Exception:
608+
need = 0
609+
if need <= 0:
610+
return existing
611+
612+
# Build exclusion and whitelist
613+
watched_imdb, watched_tmdb = await self._get_exclusion_sets()
614+
excluded_ids = set(self._get_excluded_genre_ids(content_type))
615+
whitelist = await self._get_top_genre_whitelist(content_type)
616+
617+
def _passes(item: dict) -> bool:
618+
gids = set(item.get("genre_ids") or [])
619+
if gids and excluded_ids and excluded_ids.intersection(gids):
620+
return False
621+
if whitelist:
622+
if 16 in gids and 16 not in whitelist:
623+
return False
624+
if gids and not (gids & whitelist):
625+
return False
626+
return True
627+
628+
# Seed pool from trending and top rated
629+
mtype = "tv" if content_type in ("tv", "series") else "movie"
630+
pool = []
631+
try:
632+
tr = await self.tmdb_service.get_trending(mtype, time_window="week")
633+
pool.extend(tr.get("results", [])[:60])
634+
except Exception as e:
635+
logger.warning(f"Failed to fetch trending items for padding: {e}")
636+
try:
637+
tr2 = await self.tmdb_service.get_top_rated(mtype)
638+
pool.extend(tr2.get("results", [])[:60])
639+
except Exception as e:
640+
logger.warning(f"Failed to fetch top rated items for padding: {e}")
641+
642+
# Filter and dedup by tmdb id
643+
existing_tmdb = set()
644+
for it in existing:
645+
tid = it.get("_tmdb_id") or it.get("tmdb_id") or it.get("id")
646+
try:
647+
if isinstance(tid, str) and tid.startswith("tmdb:"):
648+
tid = int(tid.split(":")[1])
649+
tid = int(tid)
650+
existing_tmdb.add(tid)
651+
except Exception:
652+
pass
653+
654+
dedup = {}
655+
for it in pool:
656+
tid = it.get("id")
657+
if not tid or tid in existing_tmdb or tid in watched_tmdb:
658+
continue
659+
if not _passes(it):
660+
continue
661+
# quality gate
662+
va = float(it.get("vote_average") or 0.0)
663+
vc = int(it.get("vote_count") or 0)
664+
if vc < 100 or va < 6.2:
665+
continue
666+
dedup[tid] = it
667+
if len(dedup) >= need * 3:
668+
break
669+
670+
if not dedup:
671+
return existing
672+
673+
# Fetch metadata to convert to enriched items and IMDB ids
674+
meta = await self._fetch_metadata_for_items(list(dedup.values()), content_type, target_count=need * 2)
675+
676+
# Filter out watched/duplicates after enrichment
677+
extra = []
678+
existing_ids = {it.get("id") for it in existing}
679+
for it in meta:
680+
if it.get("id") in existing_ids:
681+
continue
682+
if it.get("id") in watched_imdb:
683+
continue
684+
if len(extra) >= need:
685+
break
686+
extra.append(it)
687+
688+
return existing + extra
689+
601690
def _get_excluded_genre_ids(self, content_type: str) -> list[int]:
602691
if not self.user_settings:
603692
return []
@@ -823,7 +912,9 @@ async def get_recommendations(
823912
if self._library_data is None:
824913
self._library_data = await self.stremio_service.get_library_items()
825914
library_data = self._library_data
826-
all_items = library_data.get("loved", []) + library_data.get("watched", []) + library_data.get("added", [])
915+
all_items = library_data.get("loved", [])
916+
all_items += library_data.get("watched", [])
917+
all_items += library_data.get("added", [])
827918
logger.info(f"processing {len(all_items)} Items.")
828919
# Cold-start fallback remains (redundant safety)
829920
if not all_items:

app/static/index.html

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,26 @@
101101
overscroll-behavior: contain;
102102
}
103103

104+
/* High-contrast text selection to avoid blending with blue OS highlight */
105+
::selection {
106+
background: rgba(59, 130, 246, 0.35); /* blue-500 @ 35% */
107+
color: #ffffff;
108+
}
109+
::-moz-selection {
110+
background: rgba(59, 130, 246, 0.35);
111+
color: #ffffff;
112+
}
113+
input::selection, textarea::selection {
114+
background: rgba(59, 130, 246, 0.45);
115+
color: #ffffff;
116+
}
117+
input::-moz-selection, textarea::-moz-selection {
118+
background: rgba(59, 130, 246, 0.45);
119+
color: #ffffff;
120+
}
121+
/* Ensure caret is visible on dark inputs */
122+
input, textarea { caret-color: #ffffff; }
123+
104124
/* Animated hamburger icon */
105125
.hamburger {
106126
position: relative;
@@ -128,6 +148,16 @@
128148
@media (prefers-reduced-motion: reduce) {
129149
.hamburger .bar { transition: none; }
130150
}
151+
152+
/* Number input: hide native spinners; we provide custom +/- */
153+
input.stepper-input[type=number]::-webkit-outer-spin-button,
154+
input.stepper-input[type=number]::-webkit-inner-spin-button {
155+
-webkit-appearance: none;
156+
margin: 0;
157+
}
158+
input.stepper-input[type=number] {
159+
-moz-appearance: textfield;
160+
}
131161
</style>
132162
</head>
133163

app/static/script.js

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Default catalog configurations
22
const defaultCatalogs = [
3-
{ id: 'watchly.rec', name: 'Top Picks for You', enabled: true, description: 'Personalized recommendations based on your library' },
4-
{ id: 'watchly.loved', name: 'More Like', enabled: true, description: 'Recommendations similar to content you explicitly loved' },
5-
{ id: 'watchly.watched', name: 'Because You Watched', enabled: true, description: 'Recommendations based on your recent watch history' },
6-
{ id: 'watchly.theme', name: 'Genre & Keyword Catalogs', enabled: true, description: 'Dynamic catalogs based on your favorite genres, keyword, countries and many more. Just like netflix. Example: American Horror, Based on Novel or Book etc.' },
3+
{ id: 'watchly.rec', name: 'Top Picks for You', enabled: true, minItems: 20, maxItems: 24, description: 'Personalized recommendations based on your library' },
4+
{ id: 'watchly.loved', name: 'More Like', enabled: true, minItems: 20, maxItems: 24, description: 'Recommendations similar to content you explicitly loved' },
5+
{ id: 'watchly.watched', name: 'Because You Watched', enabled: true, minItems: 20, maxItems: 24, description: 'Recommendations based on your recent watch history' },
6+
{ id: 'watchly.theme', name: 'Genre & Keyword Catalogs', enabled: true, minItems: 20, maxItems: 24, description: 'Dynamic catalogs based on your favorite genres, keyword, countries and many more. Just like netflix. Example: American Horror, Based on Novel or Book etc.' },
77
];
88

99
let catalogs = JSON.parse(JSON.stringify(defaultCatalogs));
@@ -350,6 +350,8 @@ async function fetchStremioIdentity(authKey) {
350350
if (local) {
351351
local.enabled = remote.enabled;
352352
if (remote.name) local.name = remote.name;
353+
if (typeof remote.min_items === 'number') local.minItems = remote.min_items;
354+
if (typeof remote.max_items === 'number') local.maxItems = remote.max_items;
353355
}
354356
});
355357
renderCatalogList();
@@ -472,10 +474,19 @@ async function initializeFormSubmission() {
472474
const enabled = toggle.checked;
473475
const originalCatalog = catalogs.find(c => c.id === catalogId);
474476
if (originalCatalog) {
477+
let minV = parseInt(originalCatalog.minItems ?? 20, 10);
478+
let maxV = parseInt(originalCatalog.maxItems ?? 24, 10);
479+
if (Number.isNaN(minV)) minV = 20;
480+
if (Number.isNaN(maxV)) maxV = 24;
481+
// Enforce server policy: min <= 20, max <= 32, and max >= min
482+
minV = Math.max(1, Math.min(20, minV));
483+
maxV = Math.max(minV, Math.min(32, maxV));
475484
catalogsToSend.push({
476485
id: catalogId,
477486
name: originalCatalog.name,
478-
enabled: enabled
487+
enabled: enabled,
488+
min_items: minV,
489+
max_items: maxV,
479490
});
480491
}
481492
});
@@ -610,6 +621,7 @@ function createCatalogItem(cat, index) {
610621
const disabledClass = !cat.enabled ? 'opacity-50' : '';
611622
// Modern neutral glass card to match new theme
612623
item.className = `catalog-item group bg-neutral-900/60 border border-white/10 rounded-xl p-4 backdrop-blur-sm transition-all hover:border-white/20 hover:bg-neutral-900/70 hover:shadow-lg hover:shadow-black/20 ${disabledClass}`;
624+
item.setAttribute('data-id', cat.id);
613625
item.setAttribute('data-index', index);
614626

615627
const isRenamable = cat.id !== 'watchly.theme';
@@ -637,6 +649,24 @@ function createCatalogItem(cat, index) {
637649
</label>
638650
</div>
639651
<div class="catalog-desc hidden sm:block text-xs text-slate-500 mt-2 ml-8 pl-1">${escapeHtml(cat.description || '')}</div>
652+
<div class="mt-3 grid grid-cols-2 gap-3 ml-8">
653+
<div class="text-xs text-slate-400 flex items-center gap-2">
654+
<span class="whitespace-nowrap">Min items</span>
655+
<div class="flex items-center gap-2 bg-neutral-950 border border-white/10 rounded-lg px-2 py-1">
656+
<button type="button" class="btn-minus step-btn text-slate-300 hover:text-white hover:bg-white/10 rounded-md px-2 py-1" aria-label="Decrease minimum">−</button>
657+
<input type="number" name="min-items" min="1" max="20" value="${(cat.minItems ?? 20)}" class="stepper-input w-16 bg-transparent outline-none text-white text-sm text-center" />
658+
<button type="button" class="btn-plus step-btn text-slate-300 hover:text-white hover:bg-white/10 rounded-md px-2 py-1" aria-label="Increase minimum">+</button>
659+
</div>
660+
</div>
661+
<div class="text-xs text-slate-400 flex items-center gap-2">
662+
<span class="whitespace-nowrap">Max items</span>
663+
<div class="flex items-center gap-2 bg-neutral-950 border border-white/10 rounded-lg px-2 py-1">
664+
<button type="button" class="btn-minus-max step-btn text-slate-300 hover:text-white hover:bg-white/10 rounded-md px-2 py-1" aria-label="Decrease maximum">−</button>
665+
<input type="number" name="max-items" min="1" max="32" value="${(cat.maxItems ?? 24)}" class="stepper-input w-16 bg-transparent outline-none text-white text-sm text-center" />
666+
<button type="button" class="btn-plus-max step-btn text-slate-300 hover:text-white hover:bg-white/10 rounded-md px-2 py-1" aria-label="Increase maximum">+</button>
667+
</div>
668+
</div>
669+
</div>
640670
`;
641671

642672
if (isRenamable) setupRenameLogic(item, cat);
@@ -651,6 +681,36 @@ function createCatalogItem(cat, index) {
651681
item.querySelector('.move-up').addEventListener('click', (e) => { e.preventDefault(); moveCatalogUp(index); });
652682
item.querySelector('.move-down').addEventListener('click', (e) => { e.preventDefault(); moveCatalogDown(index); });
653683

684+
// keep min/max in model
685+
const minEl = item.querySelector("input[name='min-items']");
686+
const maxEl = item.querySelector("input[name='max-items']");
687+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
688+
const sync = () => {
689+
let minV = parseInt(minEl.value || '20', 10);
690+
let maxV = parseInt(maxEl.value || '24', 10);
691+
if (Number.isNaN(minV)) minV = 20;
692+
if (Number.isNaN(maxV)) maxV = 24;
693+
minV = clamp(minV, 1, 20);
694+
maxV = clamp(maxV, 1, 32);
695+
if (maxV < minV) maxV = minV;
696+
minEl.value = String(minV);
697+
maxEl.value = String(maxV);
698+
cat.minItems = minV;
699+
cat.maxItems = maxV;
700+
};
701+
minEl.addEventListener('change', sync);
702+
maxEl.addEventListener('change', sync);
703+
704+
const inc = (el, delta) => { el.value = String((parseInt(el.value || '0', 10) || 0) + delta); sync(); };
705+
const btnMinMinus = item.querySelector('.btn-minus');
706+
const btnMinPlus = item.querySelector('.btn-plus');
707+
const btnMaxMinus = item.querySelector('.btn-minus-max');
708+
const btnMaxPlus = item.querySelector('.btn-plus-max');
709+
btnMinMinus.addEventListener('click', () => inc(minEl, -1));
710+
btnMinPlus.addEventListener('click', () => inc(minEl, +1));
711+
btnMaxMinus.addEventListener('click', () => inc(maxEl, -1));
712+
btnMaxPlus.addEventListener('click', () => inc(maxEl, +1));
713+
654714
return item;
655715
}
656716

0 commit comments

Comments
 (0)