Skip to content

Commit 358ae4e

Browse files
feat: add option to enable/disable movie series catalog separately (#75)
1 parent eb13236 commit 358ae4e

File tree

4 files changed

+160
-84
lines changed

4 files changed

+160
-84
lines changed

app/api/endpoints/manifest.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,28 @@ def get_base_manifest(user_settings: UserSettings | None = None):
2727
catalogs = []
2828
else:
2929
name = rec_config.name if rec_config and rec_config.name else "Top Picks for You"
30-
catalogs = [
31-
{
32-
"type": "movie",
33-
"id": "watchly.rec",
34-
"name": name,
35-
"extra": [],
36-
},
37-
{
38-
"type": "series",
39-
"id": "watchly.rec",
40-
"name": name,
41-
"extra": [],
42-
},
43-
]
30+
enabled_movie = getattr(rec_config, "enabled_movie", True) if rec_config else True
31+
enabled_series = getattr(rec_config, "enabled_series", True) if rec_config else True
32+
33+
catalogs = []
34+
if enabled_movie:
35+
catalogs.append(
36+
{
37+
"type": "movie",
38+
"id": "watchly.rec",
39+
"name": name,
40+
"extra": [],
41+
}
42+
)
43+
if enabled_series:
44+
catalogs.append(
45+
{
46+
"type": "series",
47+
"id": "watchly.rec",
48+
"name": name,
49+
"extra": [],
50+
}
51+
)
4452

4553
return {
4654
"id": settings.ADDON_ID,

app/core/settings.py

Lines changed: 16 additions & 4 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+
enabled_movie: bool = Field(default=True, description="Enable movie catalog for this configuration")
9+
enabled_series: bool = Field(default=True, description="Enable series catalog for this configuration")
810
min_items: int = Field(default=20, ge=1, le=20)
911
max_items: int = Field(default=24, ge=1, le=32)
1012

@@ -21,10 +23,20 @@ def get_default_settings() -> UserSettings:
2123
return UserSettings(
2224
language="en-US",
2325
catalogs=[
24-
CatalogConfig(id="watchly.rec", name="Top Picks for You", enabled=True),
25-
CatalogConfig(id="watchly.loved", name="More Like", enabled=True),
26-
CatalogConfig(id="watchly.watched", name="Because you watched", enabled=True),
27-
CatalogConfig(id="watchly.theme", name="Genre & Keyword Catalogs", enabled=True),
26+
CatalogConfig(
27+
id="watchly.rec", name="Top Picks for You", enabled=True, enabled_movie=True, enabled_series=True
28+
),
29+
CatalogConfig(id="watchly.loved", name="More Like", enabled=True, enabled_movie=True, enabled_series=True),
30+
CatalogConfig(
31+
id="watchly.watched", name="Because you watched", enabled=True, enabled_movie=True, enabled_series=True
32+
),
33+
CatalogConfig(
34+
id="watchly.theme",
35+
name="Genre & Keyword Catalogs",
36+
enabled=True,
37+
enabled_movie=True,
38+
enabled_series=True,
39+
),
2840
],
2941
)
3042

app/services/catalog.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ def build_catalog_entry(self, item, label, config_id):
4949
}
5050

5151
async def get_theme_based_catalogs(
52-
self, library_items: dict, user_settings: UserSettings | None = None
52+
self,
53+
library_items: dict,
54+
user_settings: UserSettings | None = None,
55+
enabled_movie: bool = True,
56+
enabled_series: bool = True,
5357
) -> list[dict]:
5458
"""Build thematic catalogs by profiling recently watched items."""
5559
# 1. Prepare Scored History
@@ -77,22 +81,29 @@ async def _generate_for_type(media_type: str, genres: list[int]):
7781
profile = await self.user_profile_service.build_user_profile(
7882
scored_objects, content_type=media_type, excluded_genres=genres
7983
)
80-
return await self.row_generator.generate_rows(profile, media_type)
84+
try:
85+
catalogs = await self.row_generator.generate_rows(profile, media_type)
86+
return media_type, catalogs
87+
except Exception as e:
88+
logger.error(f"Failed to generate thematic rows for {media_type}: {e}")
89+
raise e
8190

82-
results = await asyncio.gather(
83-
_generate_for_type("movie", excluded_movie_genres),
84-
_generate_for_type("series", excluded_series_genres),
85-
return_exceptions=True,
86-
)
91+
tasks = []
92+
if enabled_movie:
93+
tasks.append(_generate_for_type("movie", excluded_movie_genres))
94+
if enabled_series:
95+
tasks.append(_generate_for_type("series", excluded_series_genres))
96+
97+
results = await asyncio.gather(*tasks, return_exceptions=True)
8798

8899
# 4. Assembly with error handling
89100
catalogs = []
90-
for idx, media_type in enumerate(["movie", "series"]):
91-
res = results[idx]
92-
if isinstance(res, Exception):
93-
logger.error(f"Failed to generate thematic rows for {media_type}: {res}")
101+
102+
for result in results:
103+
if isinstance(result, Exception):
94104
continue
95-
for row in res:
105+
media_type, rows = result
106+
for row in rows:
96107
catalogs.append({"type": media_type, "id": row.id, "name": row.title, "extra": []})
97108

98109
return catalogs
@@ -108,7 +119,13 @@ async def get_dynamic_catalogs(self, library_items: dict, user_settings: UserSet
108119

109120
# 2. Add Thematic Catalogs
110121
if theme_cfg and theme_cfg.enabled:
111-
catalogs.extend(await self.get_theme_based_catalogs(library_items, user_settings))
122+
# Filter theme catalogs by enabled_movie/enabled_series
123+
enabled_movie = getattr(theme_cfg, "enabled_movie", True)
124+
enabled_series = getattr(theme_cfg, "enabled_series", True)
125+
theme_catalogs = await self.get_theme_based_catalogs(
126+
library_items, user_settings, enabled_movie, enabled_series
127+
)
128+
catalogs.extend(theme_catalogs)
112129

113130
# 3. Add Item-Based Catalogs (Movies & Series)
114131
for mtype in ["movie", "series"]:
@@ -161,10 +178,19 @@ async def _add_item_based_rows(
161178
loved_config,
162179
watched_config,
163180
):
181+
# Check if this content type is enabled for the configs
182+
def is_type_enabled(config, content_type: str) -> bool:
183+
if not config:
184+
return False
185+
if content_type == "movie":
186+
return getattr(config, "enabled_movie", True)
187+
elif content_type == "series":
188+
return getattr(config, "enabled_series", True)
189+
return True
164190

165191
# 1. More Like <Loved Item>
166192
last_loved = None # Initialize for the watched check
167-
if loved_config and loved_config.enabled:
193+
if loved_config and loved_config.enabled and is_type_enabled(loved_config, content_type):
168194
loved = [i for i in library_items.get("loved", []) if i.get("type") == content_type]
169195
loved.sort(key=self._parse_item_last_watched, reverse=True)
170196

@@ -175,7 +201,7 @@ async def _add_item_based_rows(
175201
catalogs.append(self.build_catalog_entry(last_loved, label, "watchly.loved"))
176202

177203
# 2. Because you watched <Watched Item>
178-
if watched_config and watched_config.enabled:
204+
if watched_config and watched_config.enabled and is_type_enabled(watched_config, content_type):
179205
watched = [i for i in library_items.get("watched", []) if i.get("type") == content_type]
180206
watched.sort(key=self._parse_item_last_watched, reverse=True)
181207

app/static/script.js

Lines changed: 81 additions & 51 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, 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.' },
3+
{ id: 'watchly.rec', name: 'Top Picks for You', enabled: true, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Personalized recommendations based on your library' },
4+
{ id: 'watchly.loved', name: 'More Like', enabled: true, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Recommendations similar to content you explicitly loved' },
5+
{ id: 'watchly.watched', name: 'Because You Watched', enabled: true, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Recommendations based on your recent watch history' },
6+
{ id: 'watchly.theme', name: 'Genre & Keyword Catalogs', enabled: true, enabledMovie: true, enabledSeries: 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));
@@ -364,6 +364,8 @@ async function fetchStremioIdentity(authKey) {
364364
if (remote.name) local.name = remote.name;
365365
if (typeof remote.min_items === 'number') local.minItems = remote.min_items;
366366
if (typeof remote.max_items === 'number') local.maxItems = remote.max_items;
367+
if (typeof remote.enabled_movie === 'boolean') local.enabledMovie = remote.enabled_movie;
368+
if (typeof remote.enabled_series === 'boolean') local.enabledSeries = remote.enabled_series;
367369
}
368370
});
369371
renderCatalogList();
@@ -406,7 +408,7 @@ function initializeEmailPasswordLogin() {
406408
}
407409
if (!isValidEmail(email)) {
408410
showEmailPwdError('Please enter a valid email address.');
409-
try { emailInput?.focus(); } catch (e) {}
411+
try { emailInput?.focus(); } catch (e) { }
410412
return;
411413
}
412414
try {
@@ -592,10 +594,37 @@ async function initializeFormSubmission() {
592594
// Enforce server policy: min <= 20, max <= 32, and max >= min
593595
minV = Math.max(1, Math.min(20, minV));
594596
maxV = Math.max(minV, Math.min(32, maxV));
597+
598+
// Get enabled_movie and enabled_series from toggle buttons
599+
const activeBtn = document.querySelector(`.catalog-type-btn[data-catalog-id="${catalogId}"].bg-white`);
600+
let enabledMovie = true;
601+
let enabledSeries = true;
602+
603+
if (activeBtn) {
604+
const mode = activeBtn.dataset.mode;
605+
if (mode === 'movie') {
606+
enabledMovie = true;
607+
enabledSeries = false;
608+
} else if (mode === 'series') {
609+
enabledMovie = false;
610+
enabledSeries = true;
611+
} else {
612+
// 'both' or default
613+
enabledMovie = true;
614+
enabledSeries = true;
615+
}
616+
} else {
617+
// Fallback to catalog state
618+
enabledMovie = originalCatalog.enabledMovie !== false;
619+
enabledSeries = originalCatalog.enabledSeries !== false;
620+
}
621+
595622
catalogsToSend.push({
596623
id: catalogId,
597624
name: originalCatalog.name,
598625
enabled: enabled,
626+
enabled_movie: enabledMovie,
627+
enabled_series: enabledSeries,
599628
min_items: minV,
600629
max_items: maxV,
601630
});
@@ -748,6 +777,13 @@ function createCatalogItem(cat, index) {
748777
item.setAttribute('data-index', index);
749778

750779
const isRenamable = cat.id !== 'watchly.theme';
780+
781+
// Determine active mode for toggle buttons
782+
const enabledMovie = cat.enabledMovie !== false;
783+
const enabledSeries = cat.enabledSeries !== false;
784+
let activeMode = 'both';
785+
if (enabledMovie && !enabledSeries) activeMode = 'movie';
786+
else if (!enabledMovie && enabledSeries) activeMode = 'series';
751787
item.innerHTML = `
752788
<div class="flex items-start gap-3 sm:items-center sm:gap-4">
753789
<div class="sort-buttons flex flex-col gap-1 flex-shrink-0 mt-0.5 sm:mt-0">
@@ -772,22 +808,17 @@ function createCatalogItem(cat, index) {
772808
</label>
773809
</div>
774810
<div class="catalog-desc hidden sm:block text-xs text-slate-500 mt-2 ml-8 pl-1">${escapeHtml(cat.description || '')}</div>
775-
<div class="mt-3 grid grid-cols-2 gap-3 ml-8">
776-
<div class="text-xs text-slate-400 flex items-center gap-2">
777-
<span class="whitespace-nowrap">Min items</span>
778-
<div class="flex items-center gap-2 bg-neutral-950 border border-white/10 rounded-lg px-2 py-1">
779-
<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>
780-
<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" />
781-
<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>
782-
</div>
783-
</div>
784-
<div class="text-xs text-slate-400 flex items-center gap-2">
785-
<span class="whitespace-nowrap">Max items</span>
786-
<div class="flex items-center gap-2 bg-neutral-950 border border-white/10 rounded-lg px-2 py-1">
787-
<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>
788-
<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" />
789-
<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>
790-
</div>
811+
<div class="mt-3 ml-8">
812+
<div class="inline-flex items-center bg-neutral-950 border border-white/10 rounded-lg p-1" role="group" aria-label="Content type selection">
813+
<button type="button" class="catalog-type-btn px-3 py-1.5 text-sm font-medium rounded-md transition-all ${activeMode === 'both' ? 'bg-white text-black shadow-sm hover:text-black' : 'text-slate-400 hover:text-white'}" data-catalog-id="${cat.id}" data-mode="both">
814+
Both
815+
</button>
816+
<button type="button" class="catalog-type-btn px-3 py-1.5 text-sm font-medium rounded-md transition-all ${activeMode === 'movie' ? 'bg-white text-black shadow-sm hover:text-black' : 'text-slate-400 hover:text-white'}" data-catalog-id="${cat.id}" data-mode="movie">
817+
Movie
818+
</button>
819+
<button type="button" class="catalog-type-btn px-3 py-1.5 text-sm font-medium rounded-md transition-all ${activeMode === 'series' ? 'bg-white text-black shadow-sm hover:text-black' : 'text-slate-400 hover:text-white'}" data-catalog-id="${cat.id}" data-mode="series">
820+
Series
821+
</button>
791822
</div>
792823
</div>
793824
`;
@@ -801,39 +832,38 @@ function createCatalogItem(cat, index) {
801832
else item.classList.add('opacity-50');
802833
});
803834

835+
// Handle movie/series toggle button changes
836+
const allTypeButtons = item.querySelectorAll(`.catalog-type-btn[data-catalog-id="${cat.id}"]`);
837+
838+
allTypeButtons.forEach(btn => {
839+
btn.addEventListener('click', (e) => {
840+
const mode = e.target.dataset.mode;
841+
842+
// Update state
843+
if (mode === 'both') {
844+
cat.enabledMovie = true;
845+
cat.enabledSeries = true;
846+
} else if (mode === 'movie') {
847+
cat.enabledMovie = true;
848+
cat.enabledSeries = false;
849+
} else if (mode === 'series') {
850+
cat.enabledMovie = false;
851+
cat.enabledSeries = true;
852+
}
853+
854+
// Update UI
855+
allTypeButtons.forEach(b => {
856+
b.classList.remove('bg-white', 'text-black', 'shadow-sm', 'hover:text-black');
857+
b.classList.add('text-slate-400', 'hover:text-white');
858+
});
859+
e.target.classList.remove('text-slate-400', 'hover:text-white');
860+
e.target.classList.add('bg-white', 'text-black', 'shadow-sm', 'hover:text-black');
861+
});
862+
});
863+
804864
item.querySelector('.move-up').addEventListener('click', (e) => { e.preventDefault(); moveCatalogUp(index); });
805865
item.querySelector('.move-down').addEventListener('click', (e) => { e.preventDefault(); moveCatalogDown(index); });
806866

807-
// keep min/max in model
808-
const minEl = item.querySelector("input[name='min-items']");
809-
const maxEl = item.querySelector("input[name='max-items']");
810-
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
811-
const sync = () => {
812-
let minV = parseInt(minEl.value || '20', 10);
813-
let maxV = parseInt(maxEl.value || '24', 10);
814-
if (Number.isNaN(minV)) minV = 20;
815-
if (Number.isNaN(maxV)) maxV = 24;
816-
minV = clamp(minV, 1, 20);
817-
maxV = clamp(maxV, 1, 32);
818-
if (maxV < minV) maxV = minV;
819-
minEl.value = String(minV);
820-
maxEl.value = String(maxV);
821-
cat.minItems = minV;
822-
cat.maxItems = maxV;
823-
};
824-
minEl.addEventListener('change', sync);
825-
maxEl.addEventListener('change', sync);
826-
827-
const inc = (el, delta) => { el.value = String((parseInt(el.value || '0', 10) || 0) + delta); sync(); };
828-
const btnMinMinus = item.querySelector('.btn-minus');
829-
const btnMinPlus = item.querySelector('.btn-plus');
830-
const btnMaxMinus = item.querySelector('.btn-minus-max');
831-
const btnMaxPlus = item.querySelector('.btn-plus-max');
832-
btnMinMinus.addEventListener('click', () => inc(minEl, -1));
833-
btnMinPlus.addEventListener('click', () => inc(minEl, +1));
834-
btnMaxMinus.addEventListener('click', () => inc(maxEl, -1));
835-
btnMaxPlus.addEventListener('click', () => inc(maxEl, +1));
836-
837867
return item;
838868
}
839869

0 commit comments

Comments
 (0)