Karan Adhikari commited on
Commit
b5f02cc
·
unverified ·
1 Parent(s): 39d7749

* custom catalog support

Backend/__init__.py CHANGED
@@ -11,4 +11,4 @@ StartTime = time()
11
  USE_DEFAULT_ID: str = None
12
  db = Database()
13
 
14
- __version__ = "2.2.0"
 
11
  USE_DEFAULT_ID: str = None
12
  db = Database()
13
 
14
+ __version__ = "3.0.0"
Backend/fastapi/main.py CHANGED
@@ -3,6 +3,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from fastapi.staticfiles import StaticFiles
5
  from starlette.middleware.sessions import SessionMiddleware
 
6
  from Backend import __version__
7
  from Backend.fastapi.security.credentials import require_auth
8
  from Backend.fastapi.routes.stream_routes import router as stream_router, decay_client_failures
@@ -10,7 +11,8 @@ from Backend.fastapi.routes.stremio_routes import router as stremio_router
10
  from Backend.fastapi.routes.template_routes import (
11
  login_page, login_post, logout, set_theme, dashboard_page,
12
  media_management_page, edit_media_page, public_status_page, stremio_guide_page,
13
- admin_dashboard_page, admin_subscriptions_page, admin_access_page
 
14
  )
15
  from Backend.fastapi.routes.api_routes import (
16
  list_media_api, delete_media_api, update_media_api,
@@ -24,9 +26,14 @@ from Backend.fastapi.routes.api_routes import (
24
  update_subscription_plan_api, delete_subscription_plan_api,
25
  get_all_subscribers_api, manage_subscriber_api,
26
  get_all_tokens_api, assign_plan_api, link_token_user_api,
27
- search_media_rescan_api, apply_media_rescan_api
 
 
 
28
  )
29
 
 
 
30
  app = FastAPI(
31
  title="Telegram Stremio Media Server",
32
  description="A powerful, self-hosted Telegram Stremio Media Server built with FastAPI, MongoDB, and PyroFork seamlessly integrated with Stremio for automated media streaming and discovery.",
@@ -95,6 +102,11 @@ async def admin_dashboard(request: Request, _: bool = Depends(require_auth)):
95
  async def media_management(request: Request, media_type: str = "movie", _: bool = Depends(require_auth)):
96
  return await media_management_page(request, media_type, _)
97
 
 
 
 
 
 
98
  @app.get("/media/edit", response_class=HTMLResponse)
99
  async def edit_media(request: Request, tmdb_id: int, db_index: int, media_type: str, _: bool = Depends(require_auth)):
100
  return await edit_media_page(request, tmdb_id, db_index, media_type, _)
@@ -281,6 +293,63 @@ async def apply_media_rescan(
281
  ):
282
  return await apply_media_rescan_api(request, tmdb_id, db_index, media_type)
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  @app.exception_handler(401)
285
  async def auth_exception_handler(request: Request, exc):
286
  return RedirectResponse(url="/login", status_code=302)
 
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from fastapi.staticfiles import StaticFiles
5
  from starlette.middleware.sessions import SessionMiddleware
6
+ from fastapi.templating import Jinja2Templates
7
  from Backend import __version__
8
  from Backend.fastapi.security.credentials import require_auth
9
  from Backend.fastapi.routes.stream_routes import router as stream_router, decay_client_failures
 
11
  from Backend.fastapi.routes.template_routes import (
12
  login_page, login_post, logout, set_theme, dashboard_page,
13
  media_management_page, edit_media_page, public_status_page, stremio_guide_page,
14
+ admin_dashboard_page, admin_subscriptions_page, admin_access_page,
15
+ custom_catalogs_page
16
  )
17
  from Backend.fastapi.routes.api_routes import (
18
  list_media_api, delete_media_api, update_media_api,
 
26
  update_subscription_plan_api, delete_subscription_plan_api,
27
  get_all_subscribers_api, manage_subscriber_api,
28
  get_all_tokens_api, assign_plan_api, link_token_user_api,
29
+ search_media_rescan_api, apply_media_rescan_api,
30
+ list_custom_catalogs_api, create_custom_catalog_api, update_custom_catalog_api,
31
+ delete_custom_catalog_api, get_custom_catalog_items_api, search_catalog_media_api,
32
+ add_custom_catalog_item_api, remove_custom_catalog_item_api
33
  )
34
 
35
+ templates = Jinja2Templates(directory="Backend/fastapi/templates")
36
+
37
  app = FastAPI(
38
  title="Telegram Stremio Media Server",
39
  description="A powerful, self-hosted Telegram Stremio Media Server built with FastAPI, MongoDB, and PyroFork seamlessly integrated with Stremio for automated media streaming and discovery.",
 
102
  async def media_management(request: Request, media_type: str = "movie", _: bool = Depends(require_auth)):
103
  return await media_management_page(request, media_type, _)
104
 
105
+
106
+ @app.get("/catalogs", response_class=HTMLResponse)
107
+ async def custom_catalogs(request: Request, _: bool = Depends(require_auth)):
108
+ return await custom_catalogs_page(request, _)
109
+
110
  @app.get("/media/edit", response_class=HTMLResponse)
111
  async def edit_media(request: Request, tmdb_id: int, db_index: int, media_type: str, _: bool = Depends(require_auth)):
112
  return await edit_media_page(request, tmdb_id, db_index, media_type, _)
 
293
  ):
294
  return await apply_media_rescan_api(request, tmdb_id, db_index, media_type)
295
 
296
+
297
+ # --- Custom Catalog Management ---
298
+ @app.get("/api/custom-catalogs")
299
+ async def list_custom_catalogs(
300
+ tmdb_id: int | None = None,
301
+ db_index: int | None = None,
302
+ media_type: str | None = None,
303
+ _: bool = Depends(require_auth)
304
+ ):
305
+ return await list_custom_catalogs_api(tmdb_id, db_index, media_type)
306
+
307
+ @app.post("/api/custom-catalogs")
308
+ async def create_custom_catalog(payload: dict, _: bool = Depends(require_auth)):
309
+ return await create_custom_catalog_api(payload)
310
+
311
+ @app.put("/api/custom-catalogs/{catalog_id}")
312
+ async def update_custom_catalog(catalog_id: str, payload: dict, _: bool = Depends(require_auth)):
313
+ return await update_custom_catalog_api(catalog_id, payload)
314
+
315
+ @app.delete("/api/custom-catalogs/{catalog_id}")
316
+ async def delete_custom_catalog(catalog_id: str, _: bool = Depends(require_auth)):
317
+ return await delete_custom_catalog_api(catalog_id)
318
+
319
+ @app.get("/api/custom-catalogs/search-media")
320
+ async def search_catalog_media(
321
+ query: str,
322
+ media_type: str = Query("movie", regex="^(movie|tv)$"),
323
+ page: int = Query(1, ge=1),
324
+ page_size: int = Query(12, ge=1, le=50),
325
+ _: bool = Depends(require_auth)
326
+ ):
327
+ return await search_catalog_media_api(query, media_type, page, page_size)
328
+
329
+ @app.get("/api/custom-catalogs/{catalog_id}/items")
330
+ async def get_custom_catalog_items(
331
+ catalog_id: str,
332
+ media_type: str | None = Query(None, regex="^(movie|tv)$"),
333
+ page: int = Query(1, ge=1),
334
+ page_size: int = Query(24, ge=1, le=100),
335
+ _: bool = Depends(require_auth)
336
+ ):
337
+ return await get_custom_catalog_items_api(catalog_id, media_type, page, page_size)
338
+
339
+ @app.post("/api/custom-catalogs/{catalog_id}/items")
340
+ async def add_custom_catalog_item(catalog_id: str, payload: dict, _: bool = Depends(require_auth)):
341
+ return await add_custom_catalog_item_api(catalog_id, payload)
342
+
343
+ @app.delete("/api/custom-catalogs/{catalog_id}/items")
344
+ async def remove_custom_catalog_item(
345
+ catalog_id: str,
346
+ tmdb_id: int,
347
+ db_index: int,
348
+ media_type: str = Query("movie", regex="^(movie|tv)$"),
349
+ _: bool = Depends(require_auth)
350
+ ):
351
+ return await remove_custom_catalog_item_api(catalog_id, tmdb_id, db_index, media_type)
352
+
353
  @app.exception_handler(401)
354
  async def auth_exception_handler(request: Request, exc):
355
  return RedirectResponse(url="/login", status_code=302)
Backend/fastapi/routes/api_routes.py CHANGED
@@ -790,3 +790,137 @@ async def apply_media_rescan_api(request: Request, tmdb_id: int, db_index: int,
790
  "media_type": media_type,
791
  "data": updated_doc,
792
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
790
  "media_type": media_type,
791
  "data": updated_doc,
792
  }
793
+
794
+
795
+ # --- Custom Catalog APIs ---
796
+
797
+ def _normalize_media_type(media_type: str) -> str:
798
+ return "tv" if media_type in ["tv", "series"] else "movie"
799
+
800
+
801
+ async def list_custom_catalogs_api(
802
+ tmdb_id: int | None = None,
803
+ db_index: int | None = None,
804
+ media_type: str | None = None,
805
+ ):
806
+ try:
807
+ catalogs = await db.get_custom_catalogs()
808
+ if tmdb_id is not None and db_index is not None and media_type:
809
+ normalized_type = _normalize_media_type(media_type)
810
+ for catalog in catalogs:
811
+ catalog["contains_current"] = any(
812
+ int(item.get("tmdb_id", -1)) == int(tmdb_id)
813
+ and int(item.get("db_index", -1)) == int(db_index)
814
+ and item.get("media_type") == normalized_type
815
+ for item in catalog.get("items", []) or []
816
+ )
817
+ return {"catalogs": catalogs}
818
+ except Exception as e:
819
+ raise HTTPException(status_code=500, detail=str(e))
820
+
821
+
822
+ async def create_custom_catalog_api(payload: dict):
823
+ name = (payload.get("name") or "").strip()
824
+ visible = bool(payload.get("visible", True))
825
+ if not name:
826
+ raise HTTPException(status_code=400, detail="Catalog name is required.")
827
+
828
+ catalog_id = await db.create_custom_catalog(name=name, visible=visible)
829
+ if not catalog_id:
830
+ raise HTTPException(status_code=500, detail="Failed to create catalog.")
831
+
832
+ catalog = await db.get_custom_catalog(catalog_id)
833
+ return {"message": "Catalog created successfully.", "catalog": catalog}
834
+
835
+
836
+ async def update_custom_catalog_api(catalog_id: str, payload: dict):
837
+ name = payload.get("name")
838
+ visible = payload.get("visible") if "visible" in payload else None
839
+ result = await db.update_custom_catalog(catalog_id, name=name, visible=visible)
840
+ if not result:
841
+ catalog = await db.get_custom_catalog(catalog_id)
842
+ if not catalog:
843
+ raise HTTPException(status_code=404, detail="Catalog not found.")
844
+ return {"message": "Catalog updated successfully.", "catalog": await db.get_custom_catalog(catalog_id)}
845
+
846
+
847
+ async def delete_custom_catalog_api(catalog_id: str):
848
+ result = await db.delete_custom_catalog(catalog_id)
849
+ if not result:
850
+ raise HTTPException(status_code=404, detail="Catalog not found.")
851
+ return {"message": "Catalog deleted successfully."}
852
+
853
+
854
+ async def get_custom_catalog_items_api(
855
+ catalog_id: str,
856
+ media_type: str | None = None,
857
+ page: int = 1,
858
+ page_size: int = 24,
859
+ ):
860
+ try:
861
+ data = await db.get_custom_catalog_items(catalog_id, media_type, page, page_size)
862
+ if not data.get("catalog"):
863
+ raise HTTPException(status_code=404, detail="Catalog not found.")
864
+ return data
865
+ except HTTPException:
866
+ raise
867
+ except Exception as e:
868
+ raise HTTPException(status_code=500, detail=str(e))
869
+
870
+
871
+ async def search_catalog_media_api(
872
+ query: str,
873
+ media_type: str = "movie",
874
+ page: int = 1,
875
+ page_size: int = 12,
876
+ ):
877
+ query = (query or "").strip()
878
+ if not query:
879
+ return {"results": [], "total_count": 0}
880
+
881
+ try:
882
+ result = await db.search_documents(query, page, page_size)
883
+ normalized_type = _normalize_media_type(media_type)
884
+ filtered = [item for item in result.get("results", []) if item.get("media_type") == normalized_type]
885
+ return {"results": filtered, "total_count": len(filtered)}
886
+ except Exception as e:
887
+ raise HTTPException(status_code=500, detail=str(e))
888
+
889
+
890
+ async def add_custom_catalog_item_api(catalog_id: str, payload: dict):
891
+ tmdb_id = payload.get("tmdb_id")
892
+ db_index = payload.get("db_index")
893
+ media_type = _normalize_media_type(payload.get("media_type", "movie"))
894
+
895
+ if not tmdb_id or not db_index:
896
+ raise HTTPException(status_code=400, detail="tmdb_id and db_index are required.")
897
+
898
+ media = await db.get_document(media_type, int(tmdb_id), int(db_index))
899
+ if not media:
900
+ raise HTTPException(status_code=404, detail="Media not found.")
901
+
902
+ catalog = await db.get_custom_catalog(catalog_id)
903
+ if not catalog:
904
+ raise HTTPException(status_code=404, detail="Catalog not found.")
905
+
906
+ added = await db.add_item_to_custom_catalog(catalog_id, int(tmdb_id), int(db_index), media_type)
907
+ message = "Added to catalog." if added else "Already exists in this catalog."
908
+ return {"message": message, "added": added}
909
+
910
+
911
+ async def remove_custom_catalog_item_api(
912
+ catalog_id: str,
913
+ tmdb_id: int,
914
+ db_index: int,
915
+ media_type: str,
916
+ ):
917
+ catalog = await db.get_custom_catalog(catalog_id)
918
+ if not catalog:
919
+ raise HTTPException(status_code=404, detail="Catalog not found.")
920
+
921
+ removed = await db.remove_item_from_custom_catalog(
922
+ catalog_id, int(tmdb_id), int(db_index), _normalize_media_type(media_type)
923
+ )
924
+ if not removed:
925
+ return {"message": "Item was not in this catalog.", "removed": False}
926
+ return {"message": "Removed from catalog.", "removed": True}
Backend/fastapi/routes/stremio_routes.py CHANGED
@@ -159,6 +159,33 @@ async def get_manifest(token: str, token_data: dict = Depends(verify_token)):
159
  }
160
  ]
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  # Build dynamic name/description/version with subscription info
163
  addon_name = ADDON_NAME
164
  addon_desc = "Streams movies and series from your Telegram."
@@ -406,7 +433,21 @@ async def get_catalog(token: str, media_type: str, id: str, extra: Optional[str]
406
  page = (stremio_skip // PAGE_SIZE) + 1
407
 
408
  try:
409
- if search_query:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  search_results = await db.search_documents(query=search_query, page=page, page_size=PAGE_SIZE)
411
  all_items = search_results.get("results", [])
412
  db_media_type = "tv" if media_type == "series" else "movie"
 
159
  }
160
  ]
161
 
162
+ # Add visible custom catalogs to the Stremio home screen.
163
+ # Each custom catalog is exposed once for movies and once for series because
164
+ # Stremio catalogs are type-specific. Hidden catalogs remain manageable in the
165
+ # web panel, but are not included in the manifest.
166
+ try:
167
+ custom_catalogs = await db.get_custom_catalogs(visible_only=True)
168
+ for catalog in custom_catalogs:
169
+ catalog_id = str(catalog.get("_id"))
170
+ catalog_name = catalog.get("name") or "Custom Catalog"
171
+ catalogs.append({
172
+ "type": "movie",
173
+ "id": f"custom_{catalog_id}",
174
+ "name": catalog_name,
175
+ "extra": [{"name": "skip"}],
176
+ "extraSupported": ["skip"],
177
+ })
178
+ catalogs.append({
179
+ "type": "series",
180
+ "id": f"custom_{catalog_id}",
181
+ "name": catalog_name,
182
+ "extra": [{"name": "skip"}],
183
+ "extraSupported": ["skip"],
184
+ })
185
+ except Exception:
186
+ pass
187
+
188
+
189
  # Build dynamic name/description/version with subscription info
190
  addon_name = ADDON_NAME
191
  addon_desc = "Streams movies and series from your Telegram."
 
433
  page = (stremio_skip // PAGE_SIZE) + 1
434
 
435
  try:
436
+ if id.startswith("custom_"):
437
+ catalog_id = id.removeprefix("custom_")
438
+ catalog = await db.get_custom_catalog(catalog_id)
439
+ if not catalog or not catalog.get("visible", True):
440
+ return {"metas": []}
441
+
442
+ db_media_type = "tv" if media_type == "series" else "movie"
443
+ data = await db.get_custom_catalog_items(
444
+ catalog_id=catalog_id,
445
+ media_type=db_media_type,
446
+ page=page,
447
+ page_size=PAGE_SIZE,
448
+ )
449
+ items = data.get("items", [])
450
+ elif search_query:
451
  search_results = await db.search_documents(query=search_query, page=page, page_size=PAGE_SIZE)
452
  all_items = search_results.get("results", [])
453
  db_media_type = "tv" if media_type == "series" else "movie"
Backend/fastapi/templates/base.html CHANGED
@@ -197,6 +197,7 @@ tailwind.config = {
197
  <div id="active-pill" class="pointer-events-none"></div>
198
  <a href="/" class="nav-link">Home</a>
199
  <a href="/media/manage" class="nav-link">Media</a>
 
200
  <a href="/admin/dashboard" class="nav-link">Admin</a>
201
  <a href="/admin/subscriptions" class="nav-link">Subs</a>
202
  <a href="/admin/access" class="nav-link">Access</a>
@@ -249,6 +250,7 @@ tailwind.config = {
249
  <div class="glass-panel mx-2 rounded-3xl p-4 flex flex-col gap-2">
250
  <a href="/" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-house"></i> Home</a>
251
  <a href="/media/manage" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-clapperboard"></i> Media</a>
 
252
  <a href="/admin/dashboard" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-chart-line"></i> Admin</a>
253
  <a href="/admin/subscriptions" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-credit-card"></i> Subs</a>
254
  <a href="/admin/access" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-shield"></i> Access</a>
 
197
  <div id="active-pill" class="pointer-events-none"></div>
198
  <a href="/" class="nav-link">Home</a>
199
  <a href="/media/manage" class="nav-link">Media</a>
200
+ <a href="/catalogs" class="nav-link">Catalogs</a>
201
  <a href="/admin/dashboard" class="nav-link">Admin</a>
202
  <a href="/admin/subscriptions" class="nav-link">Subs</a>
203
  <a href="/admin/access" class="nav-link">Access</a>
 
250
  <div class="glass-panel mx-2 rounded-3xl p-4 flex flex-col gap-2">
251
  <a href="/" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-house"></i> Home</a>
252
  <a href="/media/manage" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-clapperboard"></i> Media</a>
253
+ <a href="/catalogs" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-layer-group"></i> Catalogs</a>
254
  <a href="/admin/dashboard" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-chart-line"></i> Admin</a>
255
  <a href="/admin/subscriptions" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-credit-card"></i> Subs</a>
256
  <a href="/admin/access" class="px-4 py-3 rounded-xl hover:bg-primary hover:text-background transition flex items-center gap-3 font-semibold"><i class="fa-solid fa-shield"></i> Access</a>
Backend/fastapi/templates/custom_catalogs.html ADDED
@@ -0,0 +1,627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Custom Catalogs - Telegram Stremio{% endblock %}
4
+
5
+ {% block content %}
6
+ <style>
7
+ .catalog-page {
8
+ --soft-bg: color-mix(in srgb, var(--bg) 78%, transparent);
9
+ --card-bg: color-mix(in srgb, var(--card) 58%, transparent);
10
+ --card-bg-strong: color-mix(in srgb, var(--card) 82%, transparent);
11
+ --line: color-mix(in srgb, var(--border) 90%, transparent);
12
+ --primary-soft: color-mix(in srgb, var(--primary) 15%, transparent);
13
+ --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
14
+ }
15
+
16
+ .glass-card {
17
+ position: relative;
18
+ overflow: hidden;
19
+ background: var(--card-bg);
20
+ border: 1px solid var(--line);
21
+ backdrop-filter: blur(22px);
22
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.18);
23
+ }
24
+
25
+ .glass-card::before {
26
+ content: "";
27
+ position: absolute;
28
+ inset: 0;
29
+ pointer-events: none;
30
+ background:
31
+ radial-gradient(circle at 12% 0%, var(--primary-soft), transparent 36%),
32
+ radial-gradient(circle at 95% 10%, var(--accent-soft), transparent 34%);
33
+ opacity: .9;
34
+ }
35
+
36
+ .glass-card > * { position: relative; z-index: 1; }
37
+
38
+ .hero-card {
39
+ min-height: 15rem;
40
+ display: grid;
41
+ place-items: stretch;
42
+ }
43
+
44
+ .hero-orb {
45
+ width: 4.25rem;
46
+ height: 4.25rem;
47
+ border-radius: 9999px;
48
+ background: linear-gradient(135deg, var(--primary), var(--accent));
49
+ box-shadow: 0 0 45px color-mix(in srgb, var(--primary) 45%, transparent);
50
+ display: grid;
51
+ place-items: center;
52
+ color: var(--bg);
53
+ }
54
+
55
+ .section-title {
56
+ color: var(--text);
57
+ font-weight: 900;
58
+ letter-spacing: -0.035em;
59
+ }
60
+
61
+ .muted { color: var(--text-sec); }
62
+
63
+ .field-label {
64
+ display: block;
65
+ font-size: 0.875rem;
66
+ font-weight: 800;
67
+ margin-bottom: 0.55rem;
68
+ color: var(--text);
69
+ }
70
+
71
+ .field-input,
72
+ .field-select {
73
+ width: 100%;
74
+ border-radius: 1rem;
75
+ border: 1px solid var(--line);
76
+ background: color-mix(in srgb, var(--bg) 84%, var(--card) 16%);
77
+ color: var(--text);
78
+ padding: 0.9rem 1rem;
79
+ outline: none;
80
+ transition: all .2s ease;
81
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 5%, transparent);
82
+ }
83
+
84
+ .field-input::placeholder { color: color-mix(in srgb, var(--text-sec) 82%, transparent); }
85
+
86
+ .field-input:focus,
87
+ .field-select:focus {
88
+ border-color: var(--primary);
89
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
90
+ }
91
+
92
+ .btn-ui {
93
+ display: inline-flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ gap: .55rem;
97
+ border-radius: 1rem;
98
+ padding: .85rem 1.15rem;
99
+ font-weight: 900;
100
+ transition: all .2s ease;
101
+ border: 1px solid transparent;
102
+ white-space: nowrap;
103
+ }
104
+
105
+ .btn-ui:hover { transform: translateY(-1px); }
106
+ .btn-ui:active { transform: translateY(0) scale(.99); }
107
+
108
+ .btn-primary {
109
+ background: linear-gradient(135deg, var(--primary), var(--accent));
110
+ color: var(--bg);
111
+ box-shadow: 0 14px 28px color-mix(in srgb, var(--primary) 24%, transparent);
112
+ }
113
+
114
+ .btn-ghost {
115
+ background: color-mix(in srgb, var(--bg) 70%, transparent);
116
+ color: var(--text);
117
+ border-color: var(--line);
118
+ }
119
+
120
+ .btn-danger {
121
+ background: color-mix(in srgb, rgb(239 68 68) 16%, var(--card));
122
+ color: rgb(248 113 113);
123
+ border-color: color-mix(in srgb, rgb(239 68 68) 32%, var(--border));
124
+ }
125
+
126
+ .pill {
127
+ display: inline-flex;
128
+ align-items: center;
129
+ gap: .45rem;
130
+ padding: .42rem .85rem;
131
+ border-radius: 9999px;
132
+ font-size: .78rem;
133
+ font-weight: 900;
134
+ border: 1px solid var(--line);
135
+ background: color-mix(in srgb, var(--bg) 66%, transparent);
136
+ color: var(--text);
137
+ }
138
+
139
+ .stat-card,
140
+ .catalog-card,
141
+ .media-card,
142
+ .empty-state {
143
+ background: color-mix(in srgb, var(--bg) 72%, var(--card) 28%);
144
+ border: 1px solid var(--line);
145
+ border-radius: 1.45rem;
146
+ transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease, background .2s ease;
147
+ }
148
+
149
+ .catalog-card:hover,
150
+ .media-card:hover {
151
+ transform: translateY(-2px);
152
+ border-color: color-mix(in srgb, var(--primary) 42%, var(--border));
153
+ box-shadow: 0 16px 34px rgba(0,0,0,.16);
154
+ }
155
+
156
+ .catalog-card.active {
157
+ border-color: var(--primary);
158
+ background:
159
+ linear-gradient(135deg, color-mix(in srgb, var(--primary) 13%, transparent), transparent),
160
+ color-mix(in srgb, var(--bg) 70%, var(--card) 30%);
161
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 14%, transparent);
162
+ }
163
+
164
+ .poster {
165
+ width: 4.25rem;
166
+ height: 6.35rem;
167
+ object-fit: cover;
168
+ border-radius: 1rem;
169
+ border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
170
+ background: color-mix(in srgb, var(--bg) 80%, transparent);
171
+ flex-shrink: 0;
172
+ }
173
+
174
+ .switch {
175
+ width: 3.1rem;
176
+ height: 1.7rem;
177
+ border-radius: 999px;
178
+ background: color-mix(in srgb, var(--border) 80%, transparent);
179
+ position: relative;
180
+ cursor: pointer;
181
+ transition: .2s ease;
182
+ border: 1px solid var(--line);
183
+ }
184
+
185
+ .switch::after {
186
+ content: "";
187
+ width: 1.25rem;
188
+ height: 1.25rem;
189
+ border-radius: 999px;
190
+ background: var(--text);
191
+ position: absolute;
192
+ left: .18rem;
193
+ top: .17rem;
194
+ transition: .2s ease;
195
+ box-shadow: 0 4px 12px rgba(0,0,0,.24);
196
+ }
197
+
198
+ .switch-input:checked + .switch {
199
+ background: linear-gradient(135deg, var(--primary), var(--accent));
200
+ }
201
+
202
+ .switch-input:checked + .switch::after {
203
+ left: 1.55rem;
204
+ background: var(--bg);
205
+ }
206
+
207
+ .mini-tab {
208
+ border-radius: 999px;
209
+ padding: .48rem .9rem;
210
+ border: 1px solid var(--line);
211
+ color: var(--text-sec);
212
+ background: color-mix(in srgb, var(--bg) 56%, transparent);
213
+ font-weight: 900;
214
+ font-size: .8rem;
215
+ }
216
+
217
+ .mini-tab.active {
218
+ color: var(--bg);
219
+ border-color: transparent;
220
+ background: linear-gradient(135deg, var(--primary), var(--accent));
221
+ }
222
+
223
+ .skeleton {
224
+ min-height: 5.5rem;
225
+ border-radius: 1.45rem;
226
+ background: linear-gradient(90deg,
227
+ color-mix(in srgb, var(--bg) 72%, var(--card) 28%),
228
+ color-mix(in srgb, var(--primary) 9%, var(--card)),
229
+ color-mix(in srgb, var(--bg) 72%, var(--card) 28%));
230
+ background-size: 220% 100%;
231
+ animation: shimmer 1.2s infinite linear;
232
+ border: 1px solid var(--line);
233
+ }
234
+
235
+ @keyframes shimmer {
236
+ to { background-position: -220% 0; }
237
+ }
238
+
239
+ @media (max-width: 640px) {
240
+ .hero-card { min-height: 13rem; }
241
+ .btn-ui { width: 100%; }
242
+ }
243
+ </style>
244
+
245
+ <div class="catalog-page max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-5 sm:py-8 space-y-6">
246
+ <div class="glass-card hero-card rounded-3xl p-5 sm:p-7 lg:p-8">
247
+ <div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
248
+ <div class="flex flex-col sm:flex-row sm:items-center gap-5">
249
+ <div class="hero-orb text-3xl">🎞️</div>
250
+ <div>
251
+ <div class="flex flex-wrap items-center gap-2 mb-3">
252
+ <span class="pill">Custom Stremio sections</span>
253
+ <span class="pill">No Stremio search/filter</span>
254
+ </div>
255
+ <h1 class="section-title text-3xl sm:text-4xl lg:text-5xl">Custom Catalogs</h1>
256
+ <p class="muted mt-3 max-w-2xl text-sm sm:text-base leading-7">
257
+ Build dynamic curated rows like Bollywood, Anime, Kids, 4K, Watch Later, or hidden private lists from your existing Telegram media.
258
+ </p>
259
+ </div>
260
+ </div>
261
+
262
+ <div class="grid grid-cols-3 gap-3 min-w-full sm:min-w-[26rem] lg:min-w-[30rem]">
263
+ <div class="stat-card p-4 text-center">
264
+ <p id="stat-total" class="section-title text-2xl">0</p>
265
+ <p class="muted text-xs font-bold mt-1">Catalogs</p>
266
+ </div>
267
+ <div class="stat-card p-4 text-center">
268
+ <p id="stat-visible" class="section-title text-2xl">0</p>
269
+ <p class="muted text-xs font-bold mt-1">Visible</p>
270
+ </div>
271
+ <div class="stat-card p-4 text-center">
272
+ <p id="stat-items" class="section-title text-2xl">0</p>
273
+ <p class="muted text-xs font-bold mt-1">Titles</p>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ </div>
278
+
279
+ <div class="grid grid-cols-1 xl:grid-cols-[0.88fr_1.12fr] gap-6">
280
+ <div class="space-y-6">
281
+ <div class="glass-card rounded-3xl p-5 sm:p-6">
282
+ <div class="flex items-start justify-between gap-3 mb-5">
283
+ <div>
284
+ <h2 class="section-title text-2xl">Create Catalog</h2>
285
+ <p class="muted text-sm mt-1">Visible catalogs appear in Stremio manifest. Hidden catalogs stay only in web panel.</p>
286
+ </div>
287
+ <span class="pill">New</span>
288
+ </div>
289
+
290
+ <form onsubmit="createCatalog(event)" class="space-y-4">
291
+ <div>
292
+ <label class="field-label">Catalog Name</label>
293
+ <input id="catalog-name" class="field-input" placeholder="Example: Bollywood Hits" required>
294
+ </div>
295
+
296
+ <label class="catalog-card p-4 flex items-center justify-between gap-4 cursor-pointer select-none">
297
+ <div>
298
+ <p class="font-black">Show in Stremio</p>
299
+ <p class="muted text-xs mt-1">Turn off to keep this catalog private in web panel.</p>
300
+ </div>
301
+ <input id="catalog-visible" type="checkbox" checked class="switch-input hidden">
302
+ <span class="switch"></span>
303
+ </label>
304
+
305
+ <button class="btn-ui btn-primary w-full" type="submit">➕ Create Catalog</button>
306
+ </form>
307
+ </div>
308
+
309
+ <div class="glass-card rounded-3xl p-5 sm:p-6">
310
+ <div class="flex items-center justify-between gap-3 mb-5">
311
+ <div>
312
+ <h2 class="section-title text-2xl">Your Catalogs</h2>
313
+ <p class="muted text-sm mt-1">Select one to manage titles.</p>
314
+ </div>
315
+ <button onclick="loadCatalogs()" class="btn-ui btn-ghost px-3 py-2 text-sm">↻</button>
316
+ </div>
317
+ <div id="catalog-list" class="space-y-3">
318
+ <div class="skeleton"></div>
319
+ <div class="skeleton"></div>
320
+ </div>
321
+ </div>
322
+ </div>
323
+
324
+ <div class="glass-card rounded-3xl p-5 sm:p-6">
325
+ <div class="flex flex-col md:flex-row md:items-start md:justify-between gap-3 mb-5">
326
+ <div>
327
+ <div class="flex flex-wrap gap-2 mb-3">
328
+ <span id="selected-visibility-pill" class="pill hidden"></span>
329
+ <span id="selected-count-pill" class="pill hidden"></span>
330
+ </div>
331
+ <h2 id="selected-title" class="section-title text-2xl">Select a catalog</h2>
332
+ <p id="selected-subtitle" class="muted text-sm mt-1">Choose a catalog from the left side to add or remove titles.</p>
333
+ </div>
334
+ <button id="delete-catalog-btn" onclick="deleteSelectedCatalog()" class="btn-ui btn-danger hidden md:w-auto">Delete Catalog</button>
335
+ </div>
336
+
337
+ <div id="catalog-empty-guide" class="empty-state p-6 sm:p-8 text-center">
338
+ <div class="text-4xl mb-3">📚</div>
339
+ <h3 class="section-title text-xl">No catalog selected</h3>
340
+ <p class="muted mt-2 text-sm leading-6">Create a catalog or select an existing one. Then search from your existing media library and add titles instantly.</p>
341
+ </div>
342
+
343
+ <div id="catalog-workspace" class="hidden space-y-6">
344
+ <div class="catalog-card p-4 sm:p-5">
345
+ <div class="flex flex-col sm:flex-row sm:items-end gap-3">
346
+ <div class="flex-1">
347
+ <label class="field-label">Search existing media</label>
348
+ <input id="search-query" class="field-input" placeholder="Search title to add..." onkeydown="if(event.key === 'Enter') searchMedia()">
349
+ </div>
350
+ <div class="sm:w-44">
351
+ <label class="field-label">Type</label>
352
+ <select id="search-media-type" class="field-select">
353
+ <option value="movie">Movie</option>
354
+ <option value="tv">Series</option>
355
+ </select>
356
+ </div>
357
+ <button onclick="searchMedia()" class="btn-ui btn-primary sm:w-auto">🔎 Search</button>
358
+ </div>
359
+ </div>
360
+
361
+ <div id="search-results-wrap" class="hidden">
362
+ <div class="flex items-center justify-between gap-3 mb-3">
363
+ <h3 class="section-title text-lg">Search Results</h3>
364
+ <button onclick="clearSearchResults()" class="mini-tab">Clear</button>
365
+ </div>
366
+ <div id="search-results" class="grid grid-cols-1 lg:grid-cols-2 gap-3"></div>
367
+ </div>
368
+
369
+ <div class="border-t pt-5" style="border-color: var(--line);">
370
+ <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
371
+ <div>
372
+ <h3 class="section-title text-lg">Titles in this catalog</h3>
373
+ <p class="muted text-xs mt-1">These are shown in Stremio when the catalog is visible.</p>
374
+ </div>
375
+ <button onclick="loadCatalogItems()" class="btn-ui btn-ghost px-3 py-2 text-sm sm:w-auto">Refresh</button>
376
+ </div>
377
+ <div id="catalog-items" class="grid grid-cols-1 lg:grid-cols-2 gap-3"></div>
378
+ </div>
379
+ </div>
380
+ </div>
381
+ </div>
382
+ </div>
383
+
384
+ <script>
385
+ let catalogs = [];
386
+ let selectedCatalogId = null;
387
+
388
+ function posterOrFallback(item) {
389
+ return item.poster || 'https://via.placeholder.com/120x180/111827/94a3b8?text=No+Poster';
390
+ }
391
+
392
+ function escapeHtml(value) {
393
+ const div = document.createElement('div');
394
+ div.textContent = value == null ? '' : String(value);
395
+ return div.innerHTML;
396
+ }
397
+
398
+ function getSelectedCatalog() {
399
+ return catalogs.find(c => c._id === selectedCatalogId);
400
+ }
401
+
402
+ function updateStats() {
403
+ const total = catalogs.length;
404
+ const visible = catalogs.filter(c => c.visible).length;
405
+ const items = catalogs.reduce((sum, c) => sum + ((c.items || []).length), 0);
406
+ document.getElementById('stat-total').textContent = total;
407
+ document.getElementById('stat-visible').textContent = visible;
408
+ document.getElementById('stat-items').textContent = items;
409
+ }
410
+
411
+ function mediaCard(item, actionHtml) {
412
+ const mediaType = item.media_type === 'tv' ? 'Series' : 'Movie';
413
+ const genres = Array.isArray(item.genres) ? item.genres.slice(0, 2).join(' · ') : '';
414
+ return `<div class="media-card p-3 flex gap-3">
415
+ <img src="${escapeHtml(posterOrFallback(item))}" class="poster" onerror="this.src='https://via.placeholder.com/120x180/111827/94a3b8?text=No+Poster'" alt="Poster">
416
+ <div class="min-w-0 flex-1">
417
+ <div class="flex items-start justify-between gap-2">
418
+ <div class="min-w-0">
419
+ <h4 class="font-black truncate" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</h4>
420
+ <p class="muted text-xs mt-1">${mediaType} · ${escapeHtml(item.release_year || 'Unknown')} · TMDb ${escapeHtml(item.tmdb_id || '')}</p>
421
+ </div>
422
+ <span class="pill text-[0.68rem] py-1 px-2">${escapeHtml(mediaType)}</span>
423
+ </div>
424
+ ${genres ? `<p class="muted text-xs mt-2 truncate">${escapeHtml(genres)}</p>` : ''}
425
+ <div class="mt-3">${actionHtml}</div>
426
+ </div>
427
+ </div>`;
428
+ }
429
+
430
+ function emptyState(icon, title, message) {
431
+ return `<div class="empty-state p-6 text-center col-span-full">
432
+ <div class="text-3xl mb-2">${icon}</div>
433
+ <h4 class="section-title text-lg">${escapeHtml(title)}</h4>
434
+ <p class="muted text-sm mt-1">${escapeHtml(message)}</p>
435
+ </div>`;
436
+ }
437
+
438
+ async function loadCatalogs() {
439
+ const box = document.getElementById('catalog-list');
440
+ box.innerHTML = '<div class="skeleton"></div><div class="skeleton"></div>';
441
+ try {
442
+ const res = await fetch('/api/custom-catalogs');
443
+ const data = await res.json().catch(() => ({}));
444
+ if (!res.ok) throw new Error(data.detail || 'Failed to load catalogs');
445
+ catalogs = data.catalogs || [];
446
+ if (selectedCatalogId && !catalogs.some(c => c._id === selectedCatalogId)) selectedCatalogId = null;
447
+ updateStats();
448
+ renderCatalogs();
449
+ updateSelectedHeader();
450
+ } catch (error) {
451
+ box.innerHTML = emptyState('⚠️', 'Unable to load catalogs', error.message || 'Please refresh and try again.');
452
+ showToast(error.message || 'Unable to load catalogs', 'error', 'Error');
453
+ }
454
+ }
455
+
456
+ function renderCatalogs() {
457
+ const box = document.getElementById('catalog-list');
458
+ if (!catalogs.length) {
459
+ box.innerHTML = emptyState('✨', 'No custom catalog yet', 'Create your first curated Stremio section above.');
460
+ return;
461
+ }
462
+ box.innerHTML = catalogs.map(c => {
463
+ const isActive = c._id === selectedCatalogId;
464
+ const count = (c.items || []).length;
465
+ return `<div class="catalog-card p-4 ${isActive ? 'active' : ''}">
466
+ <div class="flex items-start justify-between gap-3">
467
+ <button class="text-left min-w-0 flex-1" onclick="selectCatalog('${c._id}')">
468
+ <div class="flex items-center gap-2 min-w-0">
469
+ <span class="text-xl">${c.visible ? '🌐' : '🔒'}</span>
470
+ <h3 class="font-black truncate">${escapeHtml(c.name)}</h3>
471
+ </div>
472
+ <p class="muted text-xs mt-2">${count} title${count === 1 ? '' : 's'} · ${c.visible ? 'Visible in Stremio' : 'Hidden from Stremio'}</p>
473
+ </button>
474
+ <label class="flex items-center gap-2 shrink-0" title="Toggle Stremio visibility">
475
+ <input type="checkbox" ${c.visible ? 'checked' : ''} onchange="toggleVisibility('${c._id}', this.checked)" class="switch-input hidden">
476
+ <span class="switch"></span>
477
+ </label>
478
+ </div>
479
+ </div>`;
480
+ }).join('');
481
+ }
482
+
483
+ function updateSelectedHeader() {
484
+ const catalog = getSelectedCatalog();
485
+ const workspace = document.getElementById('catalog-workspace');
486
+ const guide = document.getElementById('catalog-empty-guide');
487
+ const deleteBtn = document.getElementById('delete-catalog-btn');
488
+ const visibilityPill = document.getElementById('selected-visibility-pill');
489
+ const countPill = document.getElementById('selected-count-pill');
490
+
491
+ if (!catalog) {
492
+ workspace.classList.add('hidden');
493
+ guide.classList.remove('hidden');
494
+ deleteBtn.classList.add('hidden');
495
+ visibilityPill.classList.add('hidden');
496
+ countPill.classList.add('hidden');
497
+ document.getElementById('selected-title').textContent = 'Select a catalog';
498
+ document.getElementById('selected-subtitle').textContent = 'Choose a catalog from the left side to add or remove titles.';
499
+ return;
500
+ }
501
+
502
+ workspace.classList.remove('hidden');
503
+ guide.classList.add('hidden');
504
+ deleteBtn.classList.remove('hidden');
505
+ visibilityPill.classList.remove('hidden');
506
+ countPill.classList.remove('hidden');
507
+ visibilityPill.textContent = catalog.visible ? '🌐 Visible in Stremio' : '🔒 Hidden from Stremio';
508
+ countPill.textContent = `${(catalog.items || []).length} title${(catalog.items || []).length === 1 ? '' : 's'}`;
509
+ document.getElementById('selected-title').textContent = catalog.name || 'Selected Catalog';
510
+ document.getElementById('selected-subtitle').textContent = catalog.visible
511
+ ? 'This catalog appears on the Stremio catalog screen.'
512
+ : 'This catalog is private in the web panel and hidden from Stremio.';
513
+ }
514
+
515
+ async function createCatalog(event) {
516
+ event.preventDefault();
517
+ const name = document.getElementById('catalog-name').value.trim();
518
+ const visible = document.getElementById('catalog-visible').checked;
519
+ if (!name) return showToast('Catalog name is required', 'error', 'Error');
520
+ const res = await fetch('/api/custom-catalogs', {
521
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, visible})
522
+ });
523
+ const data = await res.json().catch(() => ({}));
524
+ if (!res.ok) return showToast(data.detail || 'Failed to create catalog', 'error', 'Error');
525
+ document.getElementById('catalog-name').value = '';
526
+ showToast('Catalog created', 'success', 'Success');
527
+ await loadCatalogs();
528
+ }
529
+
530
+ async function toggleVisibility(id, visible) {
531
+ const catalog = catalogs.find(c => c._id === id);
532
+ const res = await fetch(`/api/custom-catalogs/${id}`, {
533
+ method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: catalog?.name, visible})
534
+ });
535
+ const data = await res.json().catch(() => ({}));
536
+ if (!res.ok) showToast(data.detail || 'Failed to update visibility', 'error', 'Error');
537
+ await loadCatalogs();
538
+ }
539
+
540
+ async function selectCatalog(id) {
541
+ selectedCatalogId = id;
542
+ renderCatalogs();
543
+ updateSelectedHeader();
544
+ await loadCatalogItems();
545
+ }
546
+
547
+ async function searchMedia() {
548
+ if (!selectedCatalogId) return showToast('Select a catalog first.', 'error', 'Error');
549
+ const q = document.getElementById('search-query').value.trim();
550
+ const mediaType = document.getElementById('search-media-type').value;
551
+ if (!q) return showToast('Type a title to search.', 'error', 'Error');
552
+ const wrap = document.getElementById('search-results-wrap');
553
+ const box = document.getElementById('search-results');
554
+ wrap.classList.remove('hidden');
555
+ box.innerHTML = '<div class="skeleton"></div><div class="skeleton"></div>';
556
+ try {
557
+ const res = await fetch(`/api/custom-catalogs/search-media?query=${encodeURIComponent(q)}&media_type=${mediaType}`);
558
+ const data = await res.json().catch(() => ({}));
559
+ if (!res.ok) throw new Error(data.detail || 'Search failed');
560
+ const results = data.results || [];
561
+ box.innerHTML = results.length
562
+ ? results.map(item => mediaCard(item, `<button class="btn-ui btn-primary px-3 py-2 text-sm" onclick="addItem(${item.tmdb_id}, ${item.db_index}, '${item.media_type}')">Add</button>`)).join('')
563
+ : emptyState('🔍', 'No title found', 'Try a shorter title or change movie/series type.');
564
+ } catch (error) {
565
+ box.innerHTML = emptyState('⚠️', 'Search failed', error.message || 'Please try again.');
566
+ }
567
+ }
568
+
569
+ function clearSearchResults() {
570
+ document.getElementById('search-results-wrap').classList.add('hidden');
571
+ document.getElementById('search-results').innerHTML = '';
572
+ }
573
+
574
+ async function addItem(tmdbId, dbIndex, mediaType) {
575
+ const res = await fetch(`/api/custom-catalogs/${selectedCatalogId}/items`, {
576
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({tmdb_id: tmdbId, db_index: dbIndex, media_type: mediaType})
577
+ });
578
+ const data = await res.json().catch(() => ({}));
579
+ if (!res.ok) return showToast(data.detail || 'Failed to add title', 'error', 'Error');
580
+ showToast(data.message || 'Added', 'success', 'Success');
581
+ await loadCatalogs();
582
+ await loadCatalogItems();
583
+ }
584
+
585
+ async function loadCatalogItems() {
586
+ if (!selectedCatalogId) return;
587
+ const box = document.getElementById('catalog-items');
588
+ box.innerHTML = '<div class="skeleton"></div><div class="skeleton"></div>';
589
+ try {
590
+ const res = await fetch(`/api/custom-catalogs/${selectedCatalogId}/items?page_size=100`);
591
+ const data = await res.json().catch(() => ({}));
592
+ if (!res.ok) throw new Error(data.detail || 'Failed to load catalog items');
593
+ const items = data.items || [];
594
+ box.innerHTML = items.length
595
+ ? items.map(item => mediaCard(item, `<button class="btn-ui btn-danger px-3 py-2 text-sm" onclick="removeItem(${item.tmdb_id}, ${item.db_index}, '${item.media_type}')">Remove</button>`)).join('')
596
+ : emptyState('➕', 'No titles added yet', 'Use search above to add media to this catalog.');
597
+ } catch (error) {
598
+ box.innerHTML = emptyState('⚠️', 'Unable to load titles', error.message || 'Please try again.');
599
+ }
600
+ }
601
+
602
+ async function removeItem(tmdbId, dbIndex, mediaType) {
603
+ const url = `/api/custom-catalogs/${selectedCatalogId}/items?tmdb_id=${tmdbId}&db_index=${dbIndex}&media_type=${mediaType}`;
604
+ const res = await fetch(url, { method: 'DELETE' });
605
+ const data = await res.json().catch(() => ({}));
606
+ if (!res.ok) return showToast(data.detail || 'Failed to remove title', 'error', 'Error');
607
+ showToast(data.message || 'Removed', 'success', 'Success');
608
+ await loadCatalogs();
609
+ await loadCatalogItems();
610
+ }
611
+
612
+ async function deleteSelectedCatalog() {
613
+ if (!selectedCatalogId) return;
614
+ if (!confirm('Delete this custom catalog? Titles will not be deleted from your media library.')) return;
615
+ const res = await fetch(`/api/custom-catalogs/${selectedCatalogId}`, { method: 'DELETE' });
616
+ const data = await res.json().catch(() => ({}));
617
+ if (!res.ok) return showToast(data.detail || 'Failed to delete catalog', 'error', 'Error');
618
+ selectedCatalogId = null;
619
+ clearSearchResults();
620
+ updateSelectedHeader();
621
+ showToast('Catalog deleted', 'success', 'Success');
622
+ await loadCatalogs();
623
+ }
624
+
625
+ document.addEventListener('DOMContentLoaded', loadCatalogs);
626
+ </script>
627
+ {% endblock %}
Backend/fastapi/templates/media_edit.html CHANGED
@@ -366,6 +366,32 @@
366
  </div>
367
  {% endif %}
368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  <div class="glass-card rounded-3xl p-5 sm:p-6 lg:p-8 mb-6 sm:mb-8">
370
  <div class="flex items-center justify-between gap-3 mb-5 sm:mb-6">
371
  <h3 class="section-title text-xl sm:text-2xl">Edit Details</h3>
@@ -977,7 +1003,144 @@
977
  return sessionStorage.getItem('api_token') || localStorage.getItem('api_token') || null;
978
  }
979
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
980
  document.addEventListener('DOMContentLoaded', () => {
 
 
 
981
  document.querySelectorAll('.season-content').forEach((content, index) => {
982
  if (index > 0) {
983
  content.classList.add('season-collapsed');
 
366
  </div>
367
  {% endif %}
368
 
369
+
370
+ {% if media_details %}
371
+ <div class="glass-card rounded-3xl p-5 sm:p-6 lg:p-8 mb-6 sm:mb-8">
372
+ <div class="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-3 mb-5 sm:mb-6">
373
+ <div>
374
+ <h3 class="section-title text-xl sm:text-2xl">Custom Catalog</h3>
375
+ <p class="muted mt-1 text-sm sm:text-base">Add this title directly into any custom Stremio catalog.</p>
376
+ </div>
377
+ <a href="/catalogs" class="btn-ui btn-ghost">Manage Catalogs</a>
378
+ </div>
379
+
380
+ <div class="grid grid-cols-1 lg:grid-cols-[1fr_auto_auto] gap-3">
381
+ <select id="custom-catalog-select" class="field-input">
382
+ <option value="">Loading catalogs...</option>
383
+ </select>
384
+ <button type="button" onclick="addCurrentMediaToCatalog()" class="btn-ui btn-primary">
385
+ ➕ Add to Catalog
386
+ </button>
387
+ <button type="button" onclick="removeCurrentMediaFromCatalog()" class="btn-ui btn-danger">
388
+ Remove
389
+ </button>
390
+ </div>
391
+ <p id="custom-catalog-status" class="muted text-sm mt-3">Only visible catalogs appear in Stremio. Hidden catalogs remain manageable in web panel.</p>
392
+ </div>
393
+ {% endif %}
394
+
395
  <div class="glass-card rounded-3xl p-5 sm:p-6 lg:p-8 mb-6 sm:mb-8">
396
  <div class="flex items-center justify-between gap-3 mb-5 sm:mb-6">
397
  <h3 class="section-title text-xl sm:text-2xl">Edit Details</h3>
 
1003
  return sessionStorage.getItem('api_token') || localStorage.getItem('api_token') || null;
1004
  }
1005
 
1006
+
1007
+ const CURRENT_TMDB_ID = Number("{{ tmdb_id }}");
1008
+ const CURRENT_DB_INDEX = Number("{{ db_index }}");
1009
+ const CURRENT_MEDIA_TYPE = "{{ media_type }}";
1010
+
1011
+ function setCatalogStatus(message, type = 'info') {
1012
+ const status = document.getElementById('custom-catalog-status');
1013
+ if (!status) return;
1014
+ status.textContent = message;
1015
+ status.classList.remove('text-emerald-300', 'text-rose-300', 'text-amber-300');
1016
+ if (type === 'success') status.classList.add('text-emerald-300');
1017
+ if (type === 'error') status.classList.add('text-rose-300');
1018
+ if (type === 'warn') status.classList.add('text-amber-300');
1019
+ }
1020
+
1021
+ async function loadMediaCatalogDropdown(selectedCatalogId = '') {
1022
+ const select = document.getElementById('custom-catalog-select');
1023
+ if (!select) return;
1024
+
1025
+ select.disabled = true;
1026
+ select.innerHTML = '<option value="">Loading catalogs...</option>';
1027
+ setCatalogStatus('Loading custom catalogs...');
1028
+
1029
+ try {
1030
+ const params = new URLSearchParams({
1031
+ tmdb_id: String(CURRENT_TMDB_ID),
1032
+ db_index: String(CURRENT_DB_INDEX),
1033
+ media_type: CURRENT_MEDIA_TYPE
1034
+ });
1035
+ const res = await fetch(`/api/custom-catalogs?${params.toString()}`);
1036
+ const data = await res.json().catch(() => ({}));
1037
+
1038
+ if (!res.ok) {
1039
+ throw new Error(data.detail || 'Failed to load catalogs');
1040
+ }
1041
+
1042
+ const catalogs = data.catalogs || [];
1043
+ if (!catalogs.length) {
1044
+ select.innerHTML = '<option value="">No custom catalog found</option>';
1045
+ setCatalogStatus('Create a catalog first from Manage Catalogs.', 'warn');
1046
+ return;
1047
+ }
1048
+
1049
+ select.innerHTML = catalogs.map(catalog => {
1050
+ const visibility = catalog.visible ? 'Visible' : 'Hidden';
1051
+ const added = catalog.contains_current ? ' · Added' : '';
1052
+ return `<option value="${escapeHtml(catalog._id)}" data-visible="${catalog.visible ? '1' : '0'}" data-added="${catalog.contains_current ? '1' : '0'}">${escapeHtml(catalog.name)} (${visibility}${added})</option>`;
1053
+ }).join('');
1054
+
1055
+ if (selectedCatalogId && catalogs.some(c => c._id === selectedCatalogId)) {
1056
+ select.value = selectedCatalogId;
1057
+ }
1058
+
1059
+ select.disabled = false;
1060
+ updateCatalogDropdownStatus();
1061
+ } catch (error) {
1062
+ select.innerHTML = '<option value="">Failed to load catalogs</option>';
1063
+ setCatalogStatus(error.message || 'Failed to load custom catalogs.', 'error');
1064
+ showToast(error.message || 'Failed to load custom catalogs', 'error', 'Error');
1065
+ }
1066
+ }
1067
+
1068
+ function updateCatalogDropdownStatus() {
1069
+ const select = document.getElementById('custom-catalog-select');
1070
+ if (!select || !select.value) return;
1071
+
1072
+ const option = select.options[select.selectedIndex];
1073
+ const isVisible = option?.dataset.visible === '1';
1074
+ const isAdded = option?.dataset.added === '1';
1075
+
1076
+ if (isAdded) {
1077
+ setCatalogStatus(`This title is already added to ${option.textContent.replace(' · Added', '')}.`, 'success');
1078
+ } else if (!isVisible) {
1079
+ setCatalogStatus('This catalog is hidden from Stremio main catalog screen, but you can still manage it here.', 'warn');
1080
+ } else {
1081
+ setCatalogStatus('This visible catalog will appear in Stremio main catalog screen.', 'info');
1082
+ }
1083
+ }
1084
+
1085
+ async function addCurrentMediaToCatalog() {
1086
+ const select = document.getElementById('custom-catalog-select');
1087
+ const catalogId = select?.value;
1088
+ if (!catalogId) {
1089
+ showToast('Please select a catalog first.', 'error', 'Error');
1090
+ return;
1091
+ }
1092
+
1093
+ try {
1094
+ const res = await fetch(`/api/custom-catalogs/${catalogId}/items`, {
1095
+ method: 'POST',
1096
+ headers: {'Content-Type': 'application/json'},
1097
+ body: JSON.stringify({
1098
+ tmdb_id: CURRENT_TMDB_ID,
1099
+ db_index: CURRENT_DB_INDEX,
1100
+ media_type: CURRENT_MEDIA_TYPE
1101
+ })
1102
+ });
1103
+ const data = await res.json().catch(() => ({}));
1104
+ if (!res.ok) throw new Error(data.detail || 'Failed to add title to catalog');
1105
+
1106
+ showToast(data.message || 'Added to catalog.', 'success', 'Success');
1107
+ await loadMediaCatalogDropdown(catalogId);
1108
+ } catch (error) {
1109
+ showToast(error.message || 'Failed to add title to catalog', 'error', 'Error');
1110
+ setCatalogStatus(error.message || 'Failed to add title to catalog.', 'error');
1111
+ }
1112
+ }
1113
+
1114
+ async function removeCurrentMediaFromCatalog() {
1115
+ const select = document.getElementById('custom-catalog-select');
1116
+ const catalogId = select?.value;
1117
+ if (!catalogId) {
1118
+ showToast('Please select a catalog first.', 'error', 'Error');
1119
+ return;
1120
+ }
1121
+
1122
+ try {
1123
+ const params = new URLSearchParams({
1124
+ tmdb_id: String(CURRENT_TMDB_ID),
1125
+ db_index: String(CURRENT_DB_INDEX),
1126
+ media_type: CURRENT_MEDIA_TYPE
1127
+ });
1128
+ const res = await fetch(`/api/custom-catalogs/${catalogId}/items?${params.toString()}`, {method: 'DELETE'});
1129
+ const data = await res.json().catch(() => ({}));
1130
+ if (!res.ok) throw new Error(data.detail || 'Failed to remove title from catalog');
1131
+
1132
+ showToast(data.message || 'Removed from catalog.', data.removed === false ? 'info' : 'success', 'Done');
1133
+ await loadMediaCatalogDropdown(catalogId);
1134
+ } catch (error) {
1135
+ showToast(error.message || 'Failed to remove title from catalog', 'error', 'Error');
1136
+ setCatalogStatus(error.message || 'Failed to remove title from catalog.', 'error');
1137
+ }
1138
+ }
1139
+
1140
  document.addEventListener('DOMContentLoaded', () => {
1141
+ const select = document.getElementById('custom-catalog-select');
1142
+ if (select) select.addEventListener('change', updateCatalogDropdownStatus);
1143
+ loadMediaCatalogDropdown();
1144
  document.querySelectorAll('.season-content').forEach((content, index) => {
1145
  if (index > 0) {
1146
  content.classList.add('season-collapsed');
Backend/helper/database.py CHANGED
@@ -290,6 +290,177 @@ class Database:
290
  }
291
 
292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  # -------------------------------
294
  # Helper Methods for Repeated Logic
295
  # -------------------------------
 
290
  }
291
 
292
 
293
+
294
+
295
+ # -------------------------------
296
+ # Custom Catalog Management
297
+ # -------------------------------
298
+ async def create_custom_catalog(self, name: str, visible: bool = True) -> Optional[str]:
299
+ name = (name or "").strip()
300
+ if not name:
301
+ return None
302
+
303
+ now = datetime.utcnow()
304
+ result = await self.dbs["tracking"]["custom_catalogs"].insert_one({
305
+ "name": name,
306
+ "visible": bool(visible),
307
+ "items": [],
308
+ "created_at": now,
309
+ "updated_at": now,
310
+ })
311
+ return str(result.inserted_id)
312
+
313
+ async def get_custom_catalogs(self, visible_only: bool = False) -> List[dict]:
314
+ query = {"visible": True} if visible_only else {}
315
+ cursor = self.dbs["tracking"]["custom_catalogs"].find(query).sort("updated_at", DESCENDING)
316
+ catalogs = await cursor.to_list(None)
317
+ return [convert_objectid_to_str(catalog) for catalog in catalogs]
318
+
319
+ async def get_custom_catalog(self, catalog_id: str) -> Optional[dict]:
320
+ try:
321
+ catalog = await self.dbs["tracking"]["custom_catalogs"].find_one({"_id": ObjectId(catalog_id)})
322
+ return convert_objectid_to_str(catalog) if catalog else None
323
+ except Exception:
324
+ return None
325
+
326
+ async def update_custom_catalog(self, catalog_id: str, name: Optional[str] = None, visible: Optional[bool] = None) -> bool:
327
+ update_data = {"updated_at": datetime.utcnow()}
328
+ if name is not None:
329
+ clean_name = name.strip()
330
+ if clean_name:
331
+ update_data["name"] = clean_name
332
+ if visible is not None:
333
+ update_data["visible"] = bool(visible)
334
+
335
+ try:
336
+ result = await self.dbs["tracking"]["custom_catalogs"].update_one(
337
+ {"_id": ObjectId(catalog_id)},
338
+ {"$set": update_data}
339
+ )
340
+ return result.modified_count > 0
341
+ except Exception:
342
+ return False
343
+
344
+ async def delete_custom_catalog(self, catalog_id: str) -> bool:
345
+ try:
346
+ result = await self.dbs["tracking"]["custom_catalogs"].delete_one({"_id": ObjectId(catalog_id)})
347
+ return result.deleted_count > 0
348
+ except Exception:
349
+ return False
350
+
351
+ async def add_item_to_custom_catalog(
352
+ self, catalog_id: str, tmdb_id: int, db_index: int, media_type: str
353
+ ) -> bool:
354
+ media_type = "tv" if media_type in ["tv", "series"] else "movie"
355
+ item = {
356
+ "tmdb_id": int(tmdb_id),
357
+ "db_index": int(db_index),
358
+ "media_type": media_type,
359
+ "added_at": datetime.utcnow(),
360
+ }
361
+ try:
362
+ result = await self.dbs["tracking"]["custom_catalogs"].update_one(
363
+ {
364
+ "_id": ObjectId(catalog_id),
365
+ "items": {
366
+ "$not": {
367
+ "$elemMatch": {
368
+ "tmdb_id": int(tmdb_id),
369
+ "db_index": int(db_index),
370
+ "media_type": media_type,
371
+ }
372
+ }
373
+ },
374
+ },
375
+ {
376
+ "$push": {"items": {"$each": [item], "$position": 0}},
377
+ "$set": {"updated_at": datetime.utcnow()},
378
+ }
379
+ )
380
+ return result.modified_count > 0
381
+ except Exception:
382
+ return False
383
+
384
+ async def remove_item_from_custom_catalog(
385
+ self, catalog_id: str, tmdb_id: int, db_index: int, media_type: str
386
+ ) -> bool:
387
+ media_type = "tv" if media_type in ["tv", "series"] else "movie"
388
+ try:
389
+ result = await self.dbs["tracking"]["custom_catalogs"].update_one(
390
+ {"_id": ObjectId(catalog_id)},
391
+ {
392
+ "$pull": {
393
+ "items": {
394
+ "tmdb_id": int(tmdb_id),
395
+ "db_index": int(db_index),
396
+ "media_type": media_type,
397
+ }
398
+ },
399
+ "$set": {"updated_at": datetime.utcnow()},
400
+ }
401
+ )
402
+ return result.modified_count > 0
403
+ except Exception:
404
+ return False
405
+
406
+ async def custom_catalog_contains_item(
407
+ self, catalog_id: str, tmdb_id: int, db_index: int, media_type: str
408
+ ) -> bool:
409
+ media_type = "tv" if media_type in ["tv", "series"] else "movie"
410
+ try:
411
+ catalog = await self.dbs["tracking"]["custom_catalogs"].find_one({
412
+ "_id": ObjectId(catalog_id),
413
+ "items": {
414
+ "$elemMatch": {
415
+ "tmdb_id": int(tmdb_id),
416
+ "db_index": int(db_index),
417
+ "media_type": media_type,
418
+ }
419
+ }
420
+ })
421
+ return bool(catalog)
422
+ except Exception:
423
+ return False
424
+
425
+ async def get_custom_catalog_items(
426
+ self, catalog_id: str, media_type: Optional[str] = None, page: int = 1, page_size: int = 24
427
+ ) -> dict:
428
+ catalog = await self.get_custom_catalog(catalog_id)
429
+ if not catalog:
430
+ return {"catalog": None, "items": [], "total_count": 0, "current_page": page, "total_pages": 0}
431
+
432
+ db_media_type = None
433
+ if media_type:
434
+ db_media_type = "tv" if media_type in ["tv", "series"] else "movie"
435
+
436
+ raw_items = catalog.get("items", []) or []
437
+ if db_media_type:
438
+ raw_items = [item for item in raw_items if item.get("media_type") == db_media_type]
439
+
440
+ total_count = len(raw_items)
441
+ skip = (page - 1) * page_size
442
+ selected_items = raw_items[skip:skip + page_size]
443
+
444
+ hydrated_items = []
445
+ for item in selected_items:
446
+ doc = await self.get_document(
447
+ item.get("media_type", "movie"),
448
+ int(item.get("tmdb_id")),
449
+ int(item.get("db_index", 1))
450
+ )
451
+ if doc:
452
+ hydrated_items.append(doc)
453
+
454
+ total_pages = (total_count + page_size - 1) // page_size if total_count else 0
455
+ return {
456
+ "catalog": catalog,
457
+ "items": hydrated_items,
458
+ "total_count": total_count,
459
+ "current_page": page,
460
+ "total_pages": total_pages,
461
+ }
462
+
463
+
464
  # -------------------------------
465
  # Helper Methods for Repeated Logic
466
  # -------------------------------
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "Telegram-Stremio"
3
- version = "2.2.0"
4
  description = "A powerful, self-hosted Telegram Stremio Media Server built with FastAPI, MongoDB, and PyroFork seamlessly integrated with Stremio for automated media streaming and discovery."
5
  readme = "README.md"
6
  requires-python = ">=3.11"
 
1
  [project]
2
  name = "Telegram-Stremio"
3
+ version = "3.0.0"
4
  description = "A powerful, self-hosted Telegram Stremio Media Server built with FastAPI, MongoDB, and PyroFork seamlessly integrated with Stremio for automated media streaming and discovery."
5
  readme = "README.md"
6
  requires-python = ">=3.11"