Karan Adhikari commited on
V-3.0.0
Browse files* custom catalog support
- Backend/__init__.py +1 -1
- Backend/fastapi/main.py +71 -2
- Backend/fastapi/routes/api_routes.py +134 -0
- Backend/fastapi/routes/stremio_routes.py +42 -1
- Backend/fastapi/templates/base.html +2 -0
- Backend/fastapi/templates/custom_catalogs.html +627 -0
- Backend/fastapi/templates/media_edit.html +163 -0
- Backend/helper/database.py +171 -0
- pyproject.toml +1 -1
Backend/__init__.py
CHANGED
|
@@ -11,4 +11,4 @@ StartTime = time()
|
|
| 11 |
USE_DEFAULT_ID: str = None
|
| 12 |
db = Database()
|
| 13 |
|
| 14 |
-
__version__ = "
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "
|
| 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"
|