vflixa1prime Karan Adhikari commited on
SUBSCRIPTION Introduce (#34)
Browse files* New Feature added
* Update README.md
* Update README.md
* Update README.md
* Update README.md
* 2.1.0 - SUBSCRIPTION added
* Update start.sh
---------
Co-authored-by: Karan Adhikari <doc.adhikari@gmail.com>
- Backend/__init__.py +1 -1
- Backend/__main__.py +7 -0
- Backend/config.py +5 -1
- Backend/fastapi/main.py +61 -2
- Backend/fastapi/routes/api_routes.py +245 -1
- Backend/fastapi/routes/stremio_routes.py +217 -6
- Backend/fastapi/routes/template_routes.py +28 -0
- Backend/fastapi/security/tokens.py +46 -13
- Backend/fastapi/templates/access_manage.html +439 -0
- Backend/fastapi/templates/base.html +30 -25
- Backend/fastapi/templates/dashboard.html +41 -40
- Backend/fastapi/templates/subscriptions_manage.html +343 -0
- Backend/helper/database.py +227 -1
- Backend/helper/subscription_checker.py +61 -0
- Backend/pyrofork/plugins/group_security.py +47 -0
- Backend/pyrofork/plugins/help.py +22 -0
- Backend/pyrofork/plugins/start.py +65 -5
- Backend/pyrofork/plugins/subscription.py +308 -0
- README.md +166 -2
- pyproject.toml +1 -1
- sample_config.env +23 -33
- start.sh +1 -1
- subscription_bot/package.json +13 -0
- uv.lock +0 -0
Backend/__init__.py
CHANGED
|
@@ -11,4 +11,4 @@ StartTime = time()
|
|
| 11 |
USE_DEFAULT_ID: str = None
|
| 12 |
db = Database()
|
| 13 |
|
| 14 |
-
__version__ = "2.
|
|
|
|
| 11 |
USE_DEFAULT_ID: str = None
|
| 12 |
db = Database()
|
| 13 |
|
| 14 |
+
__version__ = "2.1.0"
|
Backend/__main__.py
CHANGED
|
@@ -49,6 +49,13 @@ async def start_services():
|
|
| 49 |
link_checker_task = DeadLinkChecker(db, app, check_interval_hours=24)
|
| 50 |
loop.create_task(link_checker_task.start())
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
LOGGER.info("Telegram-Stremio Started Successfully!")
|
| 53 |
await idle()
|
| 54 |
except Exception:
|
|
|
|
| 49 |
link_checker_task = DeadLinkChecker(db, app, check_interval_hours=24)
|
| 50 |
loop.create_task(link_checker_task.start())
|
| 51 |
|
| 52 |
+
# Start Subscription Background Task
|
| 53 |
+
from Backend.config import Telegram
|
| 54 |
+
if Telegram.SUBSCRIPTION:
|
| 55 |
+
from Backend.helper.subscription_checker import subscription_checker_loop
|
| 56 |
+
loop.create_task(subscription_checker_loop(StreamBot))
|
| 57 |
+
LOGGER.info("Subscription Checker Task Started.")
|
| 58 |
+
|
| 59 |
LOGGER.info("Telegram-Stremio Started Successfully!")
|
| 60 |
await idle()
|
| 61 |
except Exception:
|
Backend/config.py
CHANGED
|
@@ -31,4 +31,8 @@ class Telegram:
|
|
| 31 |
|
| 32 |
ADMIN_USERNAME = getenv("ADMIN_USERNAME", "fyvio")
|
| 33 |
ADMIN_PASSWORD = getenv("ADMIN_PASSWORD", "fyvio")
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
ADMIN_USERNAME = getenv("ADMIN_USERNAME", "fyvio")
|
| 33 |
ADMIN_PASSWORD = getenv("ADMIN_PASSWORD", "fyvio")
|
| 34 |
+
|
| 35 |
+
SUBSCRIPTION = getenv("SUBSCRIPTION", "false").lower() == "true"
|
| 36 |
+
SUBSCRIPTION_GROUP_ID = int(getenv("SUBSCRIPTION_GROUP_ID", "0"))
|
| 37 |
+
SUBSCRIPTION_URL = getenv("SUBSCRIPTION_URL", "https://t.me/")
|
| 38 |
+
APPROVER_IDS = [int(x.strip()) for x in (getenv("APPROVER_IDS") or "").split(",") if x.strip().isdigit()]
|
Backend/fastapi/main.py
CHANGED
|
@@ -10,7 +10,7 @@ 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
|
| 14 |
)
|
| 15 |
from Backend.fastapi.routes.api_routes import (
|
| 16 |
list_media_api, delete_media_api, update_media_api,
|
|
@@ -19,7 +19,11 @@ from Backend.fastapi.routes.api_routes import (
|
|
| 19 |
create_token_api, revoke_token_api, update_token_limits_api,
|
| 20 |
speed_test_api, speed_test_stream_api,
|
| 21 |
get_admin_stats_api, clear_cache_api, get_dead_links_api,
|
| 22 |
-
get_stream_analytics_api
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
)
|
| 24 |
|
| 25 |
app = FastAPI(
|
|
@@ -176,6 +180,61 @@ async def get_dead_links(_: bool = Depends(require_auth)):
|
|
| 176 |
async def get_stream_analytics(_: bool = Depends(require_auth)):
|
| 177 |
return await get_stream_analytics_api()
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
@app.get("/api/system/speedtest")
|
| 180 |
async def speed_test(
|
| 181 |
quality_id: str = Query(...),
|
|
|
|
| 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,
|
|
|
|
| 19 |
create_token_api, revoke_token_api, update_token_limits_api,
|
| 20 |
speed_test_api, speed_test_stream_api,
|
| 21 |
get_admin_stats_api, clear_cache_api, get_dead_links_api,
|
| 22 |
+
get_stream_analytics_api,
|
| 23 |
+
get_subscription_plans_api, add_subscription_plan_api,
|
| 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 |
)
|
| 28 |
|
| 29 |
app = FastAPI(
|
|
|
|
| 180 |
async def get_stream_analytics(_: bool = Depends(require_auth)):
|
| 181 |
return await get_stream_analytics_api()
|
| 182 |
|
| 183 |
+
@app.get("/admin/subscriptions", response_class=HTMLResponse)
|
| 184 |
+
async def admin_subscriptions(request: Request, _: bool = Depends(require_auth)):
|
| 185 |
+
return await admin_subscriptions_page(request, _)
|
| 186 |
+
|
| 187 |
+
@app.get("/api/admin/subscriptions/plans")
|
| 188 |
+
async def get_subscription_plans(_: bool = Depends(require_auth)):
|
| 189 |
+
return await get_subscription_plans_api()
|
| 190 |
+
|
| 191 |
+
@app.post("/api/admin/subscriptions/plans")
|
| 192 |
+
async def add_subscription_plan(payload: dict, _: bool = Depends(require_auth)):
|
| 193 |
+
return await add_subscription_plan_api(payload)
|
| 194 |
+
|
| 195 |
+
@app.put("/api/admin/subscriptions/plans/{plan_id}")
|
| 196 |
+
async def update_subscription_plan(plan_id: str, payload: dict, _: bool = Depends(require_auth)):
|
| 197 |
+
return await update_subscription_plan_api(plan_id, payload)
|
| 198 |
+
|
| 199 |
+
@app.delete("/api/admin/subscriptions/plans/{plan_id}")
|
| 200 |
+
async def delete_subscription_plan(plan_id: str, _: bool = Depends(require_auth)):
|
| 201 |
+
return await delete_subscription_plan_api(plan_id)
|
| 202 |
+
|
| 203 |
+
@app.get("/api/admin/subscriptions/users")
|
| 204 |
+
async def get_subscribers(_: bool = Depends(require_auth)):
|
| 205 |
+
return await get_all_subscribers_api()
|
| 206 |
+
|
| 207 |
+
@app.post("/api/admin/subscriptions/users/{user_id}/manage")
|
| 208 |
+
async def manage_subscriber(user_id: int, payload: dict, _: bool = Depends(require_auth)):
|
| 209 |
+
return await manage_subscriber_api(user_id, payload)
|
| 210 |
+
|
| 211 |
+
# --- Access Management ---
|
| 212 |
+
@app.get("/admin/access", response_class=HTMLResponse)
|
| 213 |
+
async def admin_access(request: Request, _: bool = Depends(require_auth)):
|
| 214 |
+
return await admin_access_page(request, _)
|
| 215 |
+
|
| 216 |
+
@app.get("/api/admin/access/tokens")
|
| 217 |
+
async def get_access_tokens(_: bool = Depends(require_auth)):
|
| 218 |
+
return await get_all_tokens_api()
|
| 219 |
+
|
| 220 |
+
@app.delete("/api/admin/access/tokens/{token}")
|
| 221 |
+
async def delete_access_token(token: str, _: bool = Depends(require_auth)):
|
| 222 |
+
from Backend.fastapi.routes.api_routes import revoke_token_api as _revoke_token_api
|
| 223 |
+
return await _revoke_token_api(token)
|
| 224 |
+
|
| 225 |
+
@app.post("/api/admin/access/users/{user_id}/assign-plan")
|
| 226 |
+
async def assign_access_plan(user_id: int, payload: dict, _: bool = Depends(require_auth)):
|
| 227 |
+
days = int(payload.get("days", 0))
|
| 228 |
+
return await assign_plan_api(user_id, days)
|
| 229 |
+
|
| 230 |
+
@app.patch("/api/admin/access/tokens/{token}/link-user")
|
| 231 |
+
async def link_token_to_user(token: str, payload: dict, _: bool = Depends(require_auth)):
|
| 232 |
+
user_id = int(payload.get("user_id", 0))
|
| 233 |
+
if not user_id:
|
| 234 |
+
from fastapi import HTTPException
|
| 235 |
+
raise HTTPException(status_code=400, detail="user_id is required.")
|
| 236 |
+
return await link_token_user_api(token, user_id)
|
| 237 |
+
|
| 238 |
@app.get("/api/system/speedtest")
|
| 239 |
async def speed_test(
|
| 240 |
quality_id: str = Query(...),
|
Backend/fastapi/routes/api_routes.py
CHANGED
|
@@ -468,4 +468,248 @@ async def get_stream_analytics_api() -> dict:
|
|
| 468 |
except Exception as e:
|
| 469 |
from Backend.logger import LOGGER
|
| 470 |
LOGGER.error(f"Stream analytics API error: {e}")
|
| 471 |
-
return {"status": "error", "message": str(e)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
except Exception as e:
|
| 469 |
from Backend.logger import LOGGER
|
| 470 |
LOGGER.error(f"Stream analytics API error: {e}")
|
| 471 |
+
return {"status": "error", "message": str(e)}
|
| 472 |
+
|
| 473 |
+
# ---------------------------------------------------------------------------
|
| 474 |
+
# Admin Subscription Management API Routes
|
| 475 |
+
# ---------------------------------------------------------------------------
|
| 476 |
+
|
| 477 |
+
async def get_subscription_plans_api() -> dict:
|
| 478 |
+
from Backend import db
|
| 479 |
+
try:
|
| 480 |
+
plans = await db.get_subscription_plans()
|
| 481 |
+
return {"status": "success", "data": plans}
|
| 482 |
+
except Exception as e:
|
| 483 |
+
return {"status": "error", "message": str(e)}
|
| 484 |
+
|
| 485 |
+
async def add_subscription_plan_api(payload: dict) -> dict:
|
| 486 |
+
from Backend import db
|
| 487 |
+
try:
|
| 488 |
+
days = int(payload.get("days", 0))
|
| 489 |
+
price = float(payload.get("price", 0.0))
|
| 490 |
+
if days <= 0 or price < 0:
|
| 491 |
+
raise HTTPException(status_code=400, detail="Invalid plan parameters")
|
| 492 |
+
|
| 493 |
+
plan_id = await db.add_subscription_plan(days, price)
|
| 494 |
+
if plan_id:
|
| 495 |
+
return {"status": "success", "message": "Plan added successfully", "plan_id": plan_id}
|
| 496 |
+
else:
|
| 497 |
+
raise HTTPException(status_code=500, detail="Failed to add plan")
|
| 498 |
+
except HTTPException:
|
| 499 |
+
raise
|
| 500 |
+
except Exception as e:
|
| 501 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 502 |
+
|
| 503 |
+
async def update_subscription_plan_api(plan_id: str, payload: dict) -> dict:
|
| 504 |
+
from Backend import db
|
| 505 |
+
try:
|
| 506 |
+
days = int(payload.get("days", 0))
|
| 507 |
+
price = float(payload.get("price", 0.0))
|
| 508 |
+
if days <= 0 or price < 0:
|
| 509 |
+
raise HTTPException(status_code=400, detail="Invalid plan parameters")
|
| 510 |
+
|
| 511 |
+
success = await db.update_subscription_plan(plan_id, days, price)
|
| 512 |
+
if success:
|
| 513 |
+
return {"status": "success", "message": "Plan updated successfully"}
|
| 514 |
+
else:
|
| 515 |
+
raise HTTPException(status_code=404, detail="Plan not found or update failed")
|
| 516 |
+
except HTTPException:
|
| 517 |
+
raise
|
| 518 |
+
except Exception as e:
|
| 519 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 520 |
+
|
| 521 |
+
async def delete_subscription_plan_api(plan_id: str) -> dict:
|
| 522 |
+
from Backend import db
|
| 523 |
+
try:
|
| 524 |
+
success = await db.delete_subscription_plan(plan_id)
|
| 525 |
+
if success:
|
| 526 |
+
return {"status": "success", "message": "Plan deleted successfully"}
|
| 527 |
+
else:
|
| 528 |
+
raise HTTPException(status_code=404, detail="Plan not found")
|
| 529 |
+
except HTTPException:
|
| 530 |
+
raise
|
| 531 |
+
except Exception as e:
|
| 532 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 533 |
+
|
| 534 |
+
async def get_all_subscribers_api() -> dict:
|
| 535 |
+
from Backend import db
|
| 536 |
+
try:
|
| 537 |
+
users = await db.get_all_subscribers()
|
| 538 |
+
return {"status": "success", "data": users}
|
| 539 |
+
except Exception as e:
|
| 540 |
+
return {"status": "error", "message": str(e)}
|
| 541 |
+
|
| 542 |
+
async def manage_subscriber_api(user_id: int, payload: dict) -> dict:
|
| 543 |
+
from Backend import db
|
| 544 |
+
try:
|
| 545 |
+
action = payload.get("action")
|
| 546 |
+
days = int(payload.get("days", 0))
|
| 547 |
+
|
| 548 |
+
if action not in ["extend", "reduce", "delete"]:
|
| 549 |
+
raise HTTPException(status_code=400, detail="Invalid action")
|
| 550 |
+
|
| 551 |
+
success = await db.manage_subscriber(user_id, action, days)
|
| 552 |
+
if success:
|
| 553 |
+
return {"status": "success", "message": "User subscription updated successfully"}
|
| 554 |
+
else:
|
| 555 |
+
raise HTTPException(status_code=404, detail="User not found or update failed")
|
| 556 |
+
except HTTPException:
|
| 557 |
+
raise
|
| 558 |
+
except Exception as e:
|
| 559 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
# --- Access Management API ---
|
| 563 |
+
|
| 564 |
+
async def get_all_tokens_api() -> dict:
|
| 565 |
+
from Backend import db
|
| 566 |
+
from Backend.config import Telegram
|
| 567 |
+
from datetime import datetime
|
| 568 |
+
try:
|
| 569 |
+
tokens = await db.get_all_api_tokens()
|
| 570 |
+
now = datetime.utcnow()
|
| 571 |
+
result = []
|
| 572 |
+
|
| 573 |
+
# Pre-load all subscribers into a dict keyed by user_id for O(1) lookup
|
| 574 |
+
subscriber_map = {} # user_id (str) -> user doc
|
| 575 |
+
if Telegram.SUBSCRIPTION:
|
| 576 |
+
try:
|
| 577 |
+
for u in await db.get_all_subscribers():
|
| 578 |
+
uid = str(u.get("_id"))
|
| 579 |
+
subscriber_map[uid] = u
|
| 580 |
+
except Exception:
|
| 581 |
+
pass
|
| 582 |
+
|
| 583 |
+
def display_name(user, user_id, token_name=None):
|
| 584 |
+
"""Return a non-empty display name for a user."""
|
| 585 |
+
if user:
|
| 586 |
+
n = user.get("first_name") or user.get("username")
|
| 587 |
+
if n:
|
| 588 |
+
return n
|
| 589 |
+
# Fall back to the name stored on the token itself (set at creation time)
|
| 590 |
+
if token_name:
|
| 591 |
+
return token_name
|
| 592 |
+
return f"User {user_id}" if user_id else "Telegram User"
|
| 593 |
+
|
| 594 |
+
def build_entry(user_id, user, token_doc):
|
| 595 |
+
"""Build a unified access entry from optional user + token records."""
|
| 596 |
+
expiry = None
|
| 597 |
+
sub_status = None
|
| 598 |
+
user_found = bool(user)
|
| 599 |
+
|
| 600 |
+
if user:
|
| 601 |
+
sub_status = user.get("subscription_status")
|
| 602 |
+
expiry = user.get("subscription_expiry")
|
| 603 |
+
|
| 604 |
+
# Token-level expiry as fallback
|
| 605 |
+
if token_doc:
|
| 606 |
+
t_expiry = token_doc.get("subscription_expiry") or token_doc.get("expires_at")
|
| 607 |
+
if t_expiry and not expiry:
|
| 608 |
+
expiry = t_expiry
|
| 609 |
+
|
| 610 |
+
# Determine status
|
| 611 |
+
if Telegram.SUBSCRIPTION:
|
| 612 |
+
if not user_found:
|
| 613 |
+
is_expired = True
|
| 614 |
+
elif sub_status != "active":
|
| 615 |
+
is_expired = True
|
| 616 |
+
elif not expiry:
|
| 617 |
+
is_expired = True
|
| 618 |
+
else:
|
| 619 |
+
is_expired = expiry < now
|
| 620 |
+
else:
|
| 621 |
+
is_expired = bool(expiry and expiry < now)
|
| 622 |
+
|
| 623 |
+
token_str = token_doc.get("token") if token_doc else None
|
| 624 |
+
created = token_doc.get("created_at") if token_doc else (user.get("created_at") if user else None)
|
| 625 |
+
|
| 626 |
+
return {
|
| 627 |
+
"token": token_str,
|
| 628 |
+
"user_id": user_id,
|
| 629 |
+
"user_name": display_name(user, user_id, token_doc.get("name") if token_doc else None),
|
| 630 |
+
"user_found": user_found,
|
| 631 |
+
"has_token": bool(token_str),
|
| 632 |
+
"created_at": created.isoformat() if created else None,
|
| 633 |
+
"expires_at": expiry.isoformat() if expiry else None,
|
| 634 |
+
"is_expired": is_expired,
|
| 635 |
+
"sub_status": sub_status,
|
| 636 |
+
"addon_url": (
|
| 637 |
+
f"{Telegram.BASE_URL}/stremio/{token_str}/manifest.json"
|
| 638 |
+
if token_str else None
|
| 639 |
+
),
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
# Track user_ids that are already represented via a token row
|
| 643 |
+
seen_user_ids = set()
|
| 644 |
+
|
| 645 |
+
# --- 1. Process all existing tokens ---
|
| 646 |
+
for t in tokens:
|
| 647 |
+
token_user_id = t.get("user_id")
|
| 648 |
+
|
| 649 |
+
# Try to resolve user from subscriber_map using token's user_id
|
| 650 |
+
user = None
|
| 651 |
+
if token_user_id:
|
| 652 |
+
uid_str = str(token_user_id)
|
| 653 |
+
user = subscriber_map.get(uid_str)
|
| 654 |
+
if not user:
|
| 655 |
+
# Fallback: query DB if not in subscriber_map (e.g. non-active subscribers)
|
| 656 |
+
try:
|
| 657 |
+
user = await db.get_user(int(token_user_id))
|
| 658 |
+
except Exception:
|
| 659 |
+
pass
|
| 660 |
+
seen_user_ids.add(uid_str)
|
| 661 |
+
|
| 662 |
+
result.append(build_entry(token_user_id, user, t))
|
| 663 |
+
|
| 664 |
+
# --- 2. Add subscribers who have NO token ---
|
| 665 |
+
for uid_str, u in subscriber_map.items():
|
| 666 |
+
if uid_str in seen_user_ids:
|
| 667 |
+
continue # already covered by a token row
|
| 668 |
+
result.append(build_entry(u.get("_id"), u, None))
|
| 669 |
+
|
| 670 |
+
# Sort: active-with-token first, then active-no-token, expired last
|
| 671 |
+
result.sort(key=lambda x: (x["is_expired"], not x["has_token"]))
|
| 672 |
+
return {"tokens": result}
|
| 673 |
+
except Exception as e:
|
| 674 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
async def revoke_token_api(token: str) -> dict:
|
| 678 |
+
from Backend import db
|
| 679 |
+
try:
|
| 680 |
+
success = await db.revoke_api_token(token)
|
| 681 |
+
if success:
|
| 682 |
+
return {"status": "success", "message": "Token revoked."}
|
| 683 |
+
raise HTTPException(status_code=404, detail="Token not found.")
|
| 684 |
+
except HTTPException:
|
| 685 |
+
raise
|
| 686 |
+
except Exception as e:
|
| 687 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
async def assign_plan_api(user_id: int, days: int) -> dict:
|
| 691 |
+
"""Assign (or extend) a subscription for any user by user_id, even if not in DB."""
|
| 692 |
+
from Backend import db
|
| 693 |
+
try:
|
| 694 |
+
if days < 1:
|
| 695 |
+
raise HTTPException(status_code=400, detail="Days must be at least 1.")
|
| 696 |
+
result = await db.assign_subscription(user_id, days)
|
| 697 |
+
return {"status": "success", "data": result}
|
| 698 |
+
except HTTPException:
|
| 699 |
+
raise
|
| 700 |
+
except Exception as e:
|
| 701 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
async def link_token_user_api(token: str, user_id: int) -> dict:
|
| 705 |
+
"""Link an orphan token (no user_id) to a Telegram user_id."""
|
| 706 |
+
from Backend import db
|
| 707 |
+
try:
|
| 708 |
+
success = await db.link_token_user(token, user_id)
|
| 709 |
+
if success:
|
| 710 |
+
return {"status": "success", "message": f"Token linked to user {user_id}."}
|
| 711 |
+
raise HTTPException(status_code=404, detail="Token not found or already linked.")
|
| 712 |
+
except HTTPException:
|
| 713 |
+
raise
|
| 714 |
+
except Exception as e:
|
| 715 |
+
raise HTTPException(status_code=500, detail=str(e))
|
Backend/fastapi/routes/stremio_routes.py
CHANGED
|
@@ -159,23 +159,222 @@ async def get_manifest(token: str, token_data: dict = Depends(verify_token)):
|
|
| 159 |
}
|
| 160 |
]
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
return {
|
| 163 |
-
"id": "telegram.media",
|
| 164 |
-
"version":
|
| 165 |
-
"name":
|
| 166 |
"logo": "https://i.postimg.cc/XqWnmDXr/Picsart-25-10-09-08-09-45-867.png",
|
| 167 |
-
"description":
|
| 168 |
"types": ["movie", "series"],
|
| 169 |
"resources": resources,
|
| 170 |
"catalogs": catalogs,
|
| 171 |
"idPrefixes": ["tt"],
|
| 172 |
"behaviorHints": {
|
| 173 |
-
"configurable":
|
| 174 |
"configurationRequired": False
|
| 175 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
@router.get("/{token}/catalog/{media_type}/{id}/{extra:path}.json")
|
|
@@ -303,6 +502,18 @@ async def get_streams(
|
|
| 303 |
token_data: dict = Depends(verify_token)
|
| 304 |
):
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
if token_data.get("limit_exceeded"):
|
| 307 |
limit_type = token_data["limit_exceeded"]
|
| 308 |
|
|
|
|
| 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."
|
| 165 |
+
addon_version = ADDON_VERSION
|
| 166 |
+
expiry_obj = None
|
| 167 |
+
|
| 168 |
+
if Telegram.SUBSCRIPTION:
|
| 169 |
+
user_id = token_data.get("user_id")
|
| 170 |
+
if user_id:
|
| 171 |
+
from Backend import db as _db
|
| 172 |
+
try:
|
| 173 |
+
user = await _db.get_user(int(user_id))
|
| 174 |
+
if user and user.get("subscription_status") == "active":
|
| 175 |
+
expiry_obj = user.get("subscription_expiry")
|
| 176 |
+
if expiry_obj:
|
| 177 |
+
expiry_str = expiry_obj.strftime("%d %b %Y").lstrip("0")
|
| 178 |
+
addon_name = f"{ADDON_NAME} β Expires {expiry_str}"
|
| 179 |
+
addon_desc = (
|
| 180 |
+
f"π
Subscription active until {expiry_str}.\n"
|
| 181 |
+
f"Streams movies and series from your Telegram."
|
| 182 |
+
)
|
| 183 |
+
# Encode expiry epoch (low 16 bits, hex) into version so
|
| 184 |
+
# Stremio detects a change when subscription is updated.
|
| 185 |
+
epoch_tag = format(int(expiry_obj.timestamp()) & 0xFFFF, "x")
|
| 186 |
+
addon_version = f"{ADDON_VERSION}-{epoch_tag}"
|
| 187 |
+
else:
|
| 188 |
+
addon_name = f"{ADDON_NAME} β Active"
|
| 189 |
+
addon_desc = "β
Subscription active.\nStreams movies and series from your Telegram."
|
| 190 |
+
except Exception:
|
| 191 |
+
pass # Fallback to defaults on error
|
| 192 |
+
|
| 193 |
+
# Configure URL β opening this reinstalls the addon with latest manifest
|
| 194 |
+
configure_url = f"{Telegram.BASE_URL}/stremio/{token}/configure"
|
| 195 |
+
|
| 196 |
return {
|
| 197 |
+
"id": f"telegram.media.{token[:8]}", # per-user ID so each token is independent
|
| 198 |
+
"version": addon_version,
|
| 199 |
+
"name": addon_name,
|
| 200 |
"logo": "https://i.postimg.cc/XqWnmDXr/Picsart-25-10-09-08-09-45-867.png",
|
| 201 |
+
"description": addon_desc,
|
| 202 |
"types": ["movie", "series"],
|
| 203 |
"resources": resources,
|
| 204 |
"catalogs": catalogs,
|
| 205 |
"idPrefixes": ["tt"],
|
| 206 |
"behaviorHints": {
|
| 207 |
+
"configurable": True,
|
| 208 |
"configurationRequired": False
|
| 209 |
+
},
|
| 210 |
+
"config": [
|
| 211 |
+
{
|
| 212 |
+
"key": "manifest_url",
|
| 213 |
+
"title": "Your Addon URL (copy to reinstall)",
|
| 214 |
+
"type": "text",
|
| 215 |
+
"default": f"{Telegram.BASE_URL}/stremio/{token}/manifest.json"
|
| 216 |
+
}
|
| 217 |
+
]
|
| 218 |
}
|
| 219 |
|
| 220 |
|
| 221 |
+
@router.get("/{token}/configure")
|
| 222 |
+
async def configure_addon(token: str):
|
| 223 |
+
"""
|
| 224 |
+
Configure/update page for the Stremio addon.
|
| 225 |
+
Uses the correct stremio://addon_install?manifest= deep-link so Stremio
|
| 226 |
+
actually shows the Install/Update dialog when the button is clicked.
|
| 227 |
+
"""
|
| 228 |
+
from urllib.parse import quote
|
| 229 |
+
from fastapi.responses import HTMLResponse
|
| 230 |
+
from Backend import db as _db
|
| 231 |
+
|
| 232 |
+
manifest_url = f"{Telegram.BASE_URL}/stremio/{token}/manifest.json"
|
| 233 |
+
# Universal Stremio web install β works on desktop and mobile
|
| 234 |
+
web_install_url = f"https://web.stremio.com/#/?addon_manifest={quote(manifest_url, safe='')}"
|
| 235 |
+
|
| 236 |
+
# Fetch user info for display
|
| 237 |
+
token_doc = await _db.get_api_token(token)
|
| 238 |
+
user_name = "Unknown"
|
| 239 |
+
expiry_str = "N/A"
|
| 240 |
+
status_color = "#ef4444"
|
| 241 |
+
status_text = "Unknown"
|
| 242 |
+
|
| 243 |
+
if token_doc:
|
| 244 |
+
uid = token_doc.get("user_id")
|
| 245 |
+
if uid:
|
| 246 |
+
try:
|
| 247 |
+
user = await _db.get_user(int(uid))
|
| 248 |
+
if user:
|
| 249 |
+
user_name = user.get("first_name") or user.get("username") or f"User {uid}"
|
| 250 |
+
sub_status = user.get("subscription_status", "")
|
| 251 |
+
expiry = user.get("subscription_expiry")
|
| 252 |
+
if expiry:
|
| 253 |
+
expiry_str = expiry.strftime("%d %b %Y").lstrip("0")
|
| 254 |
+
if sub_status == "active":
|
| 255 |
+
status_color = "#22c55e"
|
| 256 |
+
status_text = "β
Active"
|
| 257 |
+
else:
|
| 258 |
+
status_color = "#ef4444"
|
| 259 |
+
status_text = "π΄ Expired"
|
| 260 |
+
except Exception:
|
| 261 |
+
pass
|
| 262 |
+
|
| 263 |
+
html = f"""<!DOCTYPE html>
|
| 264 |
+
<html lang="en">
|
| 265 |
+
<head>
|
| 266 |
+
<meta charset="utf-8">
|
| 267 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 268 |
+
<title>Update Telegram Stremio Addon</title>
|
| 269 |
+
<style>
|
| 270 |
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
| 271 |
+
body {{
|
| 272 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 273 |
+
background: #0f0f1a; color: #e2e8f0;
|
| 274 |
+
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
| 275 |
+
padding: 24px;
|
| 276 |
+
}}
|
| 277 |
+
.card {{
|
| 278 |
+
background: #1e1e2e; border: 1px solid #2d2d44; border-radius: 16px;
|
| 279 |
+
padding: 40px 32px; max-width: 480px; width: 100%; text-align: center;
|
| 280 |
+
}}
|
| 281 |
+
.logo {{ font-size: 48px; margin-bottom: 12px; }}
|
| 282 |
+
h1 {{ font-size: 1.5rem; font-weight: 700; color: #f8fafc; margin-bottom: 6px; }}
|
| 283 |
+
.sub-title {{ color: #94a3b8; font-size: 0.9rem; margin-bottom: 28px; }}
|
| 284 |
+
.info-row {{
|
| 285 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 286 |
+
background: #2a2a3e; border-radius: 10px; padding: 12px 16px;
|
| 287 |
+
margin-bottom: 12px; font-size: 0.9rem;
|
| 288 |
+
}}
|
| 289 |
+
.info-label {{ color: #94a3b8; }}
|
| 290 |
+
.info-val {{ font-weight: 600; color: #f1f5f9; }}
|
| 291 |
+
.status-badge {{
|
| 292 |
+
display: inline-block; padding: 2px 10px; border-radius: 999px;
|
| 293 |
+
font-size: 0.8rem; font-weight: 700;
|
| 294 |
+
background: {status_color}22; color: {status_color};
|
| 295 |
+
}}
|
| 296 |
+
.btn-update {{
|
| 297 |
+
display: block; width: 100%;
|
| 298 |
+
background: linear-gradient(135deg, #7c3aed, #4f46e5);
|
| 299 |
+
color: white; font-weight: 700; font-size: 1rem;
|
| 300 |
+
padding: 14px 24px; border-radius: 12px; border: none;
|
| 301 |
+
cursor: pointer; text-decoration: none; margin: 28px 0 12px;
|
| 302 |
+
transition: opacity 0.2s;
|
| 303 |
+
}}
|
| 304 |
+
.btn-update:hover {{ opacity: 0.85; }}
|
| 305 |
+
.btn-web {{
|
| 306 |
+
display: block; color: #6366f1; font-size: 0.85rem;
|
| 307 |
+
text-decoration: underline; margin-bottom: 20px;
|
| 308 |
+
}}
|
| 309 |
+
.steps {{
|
| 310 |
+
background: #2a2a3e; border-radius: 10px; padding: 14px 18px;
|
| 311 |
+
margin: 16px 0; text-align: left; font-size: 0.85rem; color: #cbd5e1;
|
| 312 |
+
}}
|
| 313 |
+
.steps b {{ color: #f1f5f9; }}
|
| 314 |
+
.steps ol {{ margin-top: 8px; margin-left: 18px; line-height: 1.8; }}
|
| 315 |
+
.url-box {{
|
| 316 |
+
background: #111827; border: 1px solid #374151; border-radius: 8px;
|
| 317 |
+
padding: 10px 14px; font-family: monospace; font-size: 0.75rem;
|
| 318 |
+
color: #94a3b8; word-break: break-all; text-align: left; margin-top: 16px;
|
| 319 |
+
}}
|
| 320 |
+
.btn-copy {{
|
| 321 |
+
margin-top: 10px; width: 100%; padding: 10px;
|
| 322 |
+
background: #1e293b; border: 1px solid #374151; color: #94a3b8;
|
| 323 |
+
border-radius: 8px; cursor: pointer; font-size: 0.85rem; transition: all 0.2s;
|
| 324 |
+
}}
|
| 325 |
+
.btn-copy:hover {{ background: #334155; color: #f1f5f9; }}
|
| 326 |
+
.hint {{ color: #64748b; font-size: 0.78rem; margin-top: 6px; }}
|
| 327 |
+
</style>
|
| 328 |
+
</head>
|
| 329 |
+
<body>
|
| 330 |
+
<div class="card">
|
| 331 |
+
<div class="logo">π¬</div>
|
| 332 |
+
<h1>Telegram Stremio Addon</h1>
|
| 333 |
+
<p class="sub-title">Click the button below to install or update your addon in Stremio.</p>
|
| 334 |
+
|
| 335 |
+
<div class="info-row">
|
| 336 |
+
<span class="info-label">User</span>
|
| 337 |
+
<span class="info-val">{user_name}</span>
|
| 338 |
+
</div>
|
| 339 |
+
<div class="info-row">
|
| 340 |
+
<span class="info-label">Status</span>
|
| 341 |
+
<span class="status-badge">{status_text}</span>
|
| 342 |
+
</div>
|
| 343 |
+
<div class="info-row">
|
| 344 |
+
<span class="info-label">Expires</span>
|
| 345 |
+
<span class="info-val">{expiry_str}</span>
|
| 346 |
+
</div>
|
| 347 |
+
|
| 348 |
+
<a href="{web_install_url}" class="btn-update" target="_blank">
|
| 349 |
+
β‘ Install / Update in Stremio
|
| 350 |
+
</a>
|
| 351 |
+
|
| 352 |
+
<div class="steps">
|
| 353 |
+
<b>Or install manually:</b>
|
| 354 |
+
<ol>
|
| 355 |
+
<li>Open Stremio β <b>Add-ons</b> tab</li>
|
| 356 |
+
<li>Click the <b>π Search / URL</b> icon</li>
|
| 357 |
+
<li>Paste the URL below and press Enter</li>
|
| 358 |
+
</ol>
|
| 359 |
+
</div>
|
| 360 |
+
|
| 361 |
+
<div class="url-box" id="murl">{manifest_url}</div>
|
| 362 |
+
<button onclick="copyUrl()" class="btn-copy">π Copy URL</button>
|
| 363 |
+
<script>
|
| 364 |
+
function copyUrl() {{
|
| 365 |
+
navigator.clipboard.writeText('{manifest_url}').then(() => {{
|
| 366 |
+
const b = document.querySelector('.btn-copy');
|
| 367 |
+
b.textContent = 'β
Copied!';
|
| 368 |
+
setTimeout(() => b.textContent = 'π Copy URL', 2000);
|
| 369 |
+
}});
|
| 370 |
+
}}
|
| 371 |
+
</script>
|
| 372 |
+
</div>
|
| 373 |
+
</body>
|
| 374 |
+
</html>"""
|
| 375 |
+
return HTMLResponse(html)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
|
| 379 |
|
| 380 |
@router.get("/{token}/catalog/{media_type}/{id}/{extra:path}.json")
|
|
|
|
| 502 |
token_data: dict = Depends(verify_token)
|
| 503 |
):
|
| 504 |
|
| 505 |
+
if token_data.get("subscription_expired"):
|
| 506 |
+
from Backend.config import Telegram as _TG
|
| 507 |
+
return {
|
| 508 |
+
"streams": [
|
| 509 |
+
{
|
| 510 |
+
"name": "π« Subscription Expired",
|
| 511 |
+
"title": "Your subscription has expired.\nRenew via the bot to continue watching.",
|
| 512 |
+
"url": _TG.SUBSCRIPTION_URL
|
| 513 |
+
}
|
| 514 |
+
]
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
if token_data.get("limit_exceeded"):
|
| 518 |
limit_type = token_data["limit_exceeded"]
|
| 519 |
|
Backend/fastapi/routes/template_routes.py
CHANGED
|
@@ -239,3 +239,31 @@ async def stremio_guide_page(request: Request):
|
|
| 239 |
"current_theme": theme_name,
|
| 240 |
"is_authenticated": is_authenticated(request)
|
| 241 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
"current_theme": theme_name,
|
| 240 |
"is_authenticated": is_authenticated(request)
|
| 241 |
})
|
| 242 |
+
|
| 243 |
+
async def admin_subscriptions_page(request: Request, _: bool = Depends(require_auth)):
|
| 244 |
+
theme_name = request.session.get("theme", "purple_gradient")
|
| 245 |
+
theme = get_theme(theme_name)
|
| 246 |
+
current_user = get_current_user(request)
|
| 247 |
+
|
| 248 |
+
return templates.TemplateResponse("subscriptions_manage.html", {
|
| 249 |
+
"request": request,
|
| 250 |
+
"theme": theme,
|
| 251 |
+
"themes": get_all_themes(),
|
| 252 |
+
"current_theme": theme_name,
|
| 253 |
+
"current_user": current_user
|
| 254 |
+
})
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
async def admin_access_page(request: Request, _: bool = Depends(require_auth)):
|
| 258 |
+
theme_name = request.session.get("theme", "purple_gradient")
|
| 259 |
+
theme = get_theme(theme_name)
|
| 260 |
+
current_user = get_current_user(request)
|
| 261 |
+
|
| 262 |
+
return templates.TemplateResponse("access_manage.html", {
|
| 263 |
+
"request": request,
|
| 264 |
+
"theme": theme,
|
| 265 |
+
"themes": get_all_themes(),
|
| 266 |
+
"current_theme": theme_name,
|
| 267 |
+
"current_user": current_user,
|
| 268 |
+
})
|
| 269 |
+
|
Backend/fastapi/security/tokens.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
from fastapi import HTTPException
|
|
|
|
| 2 |
from Backend import db
|
|
|
|
| 3 |
|
| 4 |
DAILY_LIMIT_VIDEO = "https://bit.ly/3YZFKT5"
|
| 5 |
MONTHLY_LIMIT_VIDEO = "https://bit.ly/4rfjtgd"
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
async def verify_token(token: str):
|
|
@@ -13,24 +16,54 @@ async def verify_token(token: str):
|
|
| 13 |
limits = token_data.get("limits", {})
|
| 14 |
usage = token_data.get("usage", {})
|
| 15 |
|
| 16 |
-
daily_limit = limits.get("daily_limit_gb")
|
| 17 |
-
monthly_limit = limits.get("monthly_limit_gb")
|
| 18 |
-
|
| 19 |
token_data["limit_exceeded"] = None
|
| 20 |
token_data["limit_video"] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
token_data["limit_exceeded"] = "daily"
|
| 26 |
-
token_data["limit_video"] = DAILY_LIMIT_VIDEO
|
| 27 |
return token_data
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
token_data["limit_exceeded"] = "monthly"
|
| 33 |
-
token_data["limit_video"] = MONTHLY_LIMIT_VIDEO
|
| 34 |
return token_data
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return token_data
|
|
|
|
| 1 |
from fastapi import HTTPException
|
| 2 |
+
from datetime import datetime
|
| 3 |
from Backend import db
|
| 4 |
+
from Backend.config import Telegram
|
| 5 |
|
| 6 |
DAILY_LIMIT_VIDEO = "https://bit.ly/3YZFKT5"
|
| 7 |
MONTHLY_LIMIT_VIDEO = "https://bit.ly/4rfjtgd"
|
| 8 |
+
SUBSCRIPTION_EXPIRED_VIDEO = "https://bit.ly/4rfjtgd"
|
| 9 |
|
| 10 |
|
| 11 |
async def verify_token(token: str):
|
|
|
|
| 16 |
limits = token_data.get("limits", {})
|
| 17 |
usage = token_data.get("usage", {})
|
| 18 |
|
|
|
|
|
|
|
|
|
|
| 19 |
token_data["limit_exceeded"] = None
|
| 20 |
token_data["limit_video"] = None
|
| 21 |
+
token_data["subscription_expired"] = False
|
| 22 |
+
|
| 23 |
+
# --- Subscription expiry check (only when SUBSCRIPTION feature is enabled) ---
|
| 24 |
+
if Telegram.SUBSCRIPTION:
|
| 25 |
+
user_id = token_data.get("user_id")
|
| 26 |
+
if not user_id:
|
| 27 |
+
# Token has no linked user β treat as expired (unverified token)
|
| 28 |
+
token_data["subscription_expired"] = True
|
| 29 |
+
return token_data
|
| 30 |
|
| 31 |
+
user = await db.get_user(int(user_id))
|
| 32 |
+
if not user or user.get("subscription_status") != "active":
|
| 33 |
+
token_data["subscription_expired"] = True
|
|
|
|
|
|
|
| 34 |
return token_data
|
| 35 |
|
| 36 |
+
expiry = user.get("subscription_expiry")
|
| 37 |
+
if not expiry:
|
| 38 |
+
token_data["subscription_expired"] = True
|
|
|
|
|
|
|
| 39 |
return token_data
|
| 40 |
|
| 41 |
+
# Compare correctly regardless of timezone awareness
|
| 42 |
+
now = datetime.utcnow()
|
| 43 |
+
try:
|
| 44 |
+
if expiry.tzinfo is not None:
|
| 45 |
+
from datetime import timezone
|
| 46 |
+
now = datetime.now(timezone.utc)
|
| 47 |
+
except AttributeError:
|
| 48 |
+
pass
|
| 49 |
+
if expiry < now:
|
| 50 |
+
token_data["subscription_expired"] = True
|
| 51 |
+
return token_data
|
| 52 |
+
|
| 53 |
+
if daily_limit := limits.get("daily_limit_gb"):
|
| 54 |
+
if daily_limit > 0:
|
| 55 |
+
current_daily_gb = usage.get("daily", {}).get("bytes", 0) / (1024 ** 3)
|
| 56 |
+
if current_daily_gb >= daily_limit:
|
| 57 |
+
token_data["limit_exceeded"] = "daily"
|
| 58 |
+
token_data["limit_video"] = DAILY_LIMIT_VIDEO
|
| 59 |
+
return token_data
|
| 60 |
+
|
| 61 |
+
if monthly_limit := limits.get("monthly_limit_gb"):
|
| 62 |
+
if monthly_limit > 0:
|
| 63 |
+
current_monthly_gb = usage.get("monthly", {}).get("bytes", 0) / (1024 ** 3)
|
| 64 |
+
if current_monthly_gb >= monthly_limit:
|
| 65 |
+
token_data["limit_exceeded"] = "monthly"
|
| 66 |
+
token_data["limit_video"] = MONTHLY_LIMIT_VIDEO
|
| 67 |
+
return token_data
|
| 68 |
+
|
| 69 |
return token_data
|
Backend/fastapi/templates/access_manage.html
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Access Management{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
<div class="container mx-auto px-4 py-8 max-w-7xl">
|
| 5 |
+
|
| 6 |
+
<!-- Header -->
|
| 7 |
+
<div class="flex items-center justify-between mb-6">
|
| 8 |
+
<div>
|
| 9 |
+
<h2 class="text-3xl font-bold text-primary">π Access Management</h2>
|
| 10 |
+
<p class="text-sm text-text opacity-60 mt-1">Manage API tokens for Stremio addon access</p>
|
| 11 |
+
</div>
|
| 12 |
+
<button onclick="loadTokens()"
|
| 13 |
+
class="bg-primary text-white px-4 py-2 rounded-lg hover:opacity-80 transition text-sm font-medium">
|
| 14 |
+
π Refresh
|
| 15 |
+
</button>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<!-- Stats -->
|
| 19 |
+
<div class="grid grid-cols-3 gap-4 mb-6">
|
| 20 |
+
<div class="theme-card rounded-xl p-4 text-center">
|
| 21 |
+
<div class="text-2xl font-bold text-primary" id="stat-total">β</div>
|
| 22 |
+
<div class="text-xs text-text opacity-60 mt-1">Total Tokens</div>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="theme-card rounded-xl p-4 text-center">
|
| 25 |
+
<div class="text-2xl font-bold text-green-500" id="stat-active">β</div>
|
| 26 |
+
<div class="text-xs text-text opacity-60 mt-1">Active</div>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="theme-card rounded-xl p-4 text-center">
|
| 29 |
+
<div class="text-2xl font-bold text-red-400" id="stat-expired">β</div>
|
| 30 |
+
<div class="text-xs text-text opacity-60 mt-1">Expired / No Sub</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<!-- Search & Filter bar -->
|
| 35 |
+
<div class="flex flex-wrap gap-3 mb-4 items-center">
|
| 36 |
+
<input id="search-input" type="text" placeholder="π Search by name or user IDβ¦"
|
| 37 |
+
class="flex-1 min-w-[200px] border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-primary bg-white dark:bg-gray-800 text-text"
|
| 38 |
+
oninput="renderPage()">
|
| 39 |
+
<select id="filter-status"
|
| 40 |
+
class="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none bg-white dark:bg-gray-800 text-text"
|
| 41 |
+
onchange="renderPage()">
|
| 42 |
+
<option value="all">All Status</option>
|
| 43 |
+
<option value="active">π’ Active</option>
|
| 44 |
+
<option value="expired">π΄ Expired</option>
|
| 45 |
+
</select>
|
| 46 |
+
<select id="page-size"
|
| 47 |
+
class="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none bg-white dark:bg-gray-800 text-text"
|
| 48 |
+
onchange="currentPage=1; renderPage()">
|
| 49 |
+
<option value="10">10 / page</option>
|
| 50 |
+
<option value="25">25 / page</option>
|
| 51 |
+
<option value="50">50 / page</option>
|
| 52 |
+
</select>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<!-- Token Table -->
|
| 56 |
+
<div class="theme-card rounded-2xl shadow overflow-hidden">
|
| 57 |
+
<div class="overflow-x-auto">
|
| 58 |
+
<table class="w-full text-sm">
|
| 59 |
+
<thead>
|
| 60 |
+
<tr class="border-b border-gray-200 dark:border-gray-700">
|
| 61 |
+
<th class="px-4 py-3 text-left text-text opacity-70 font-semibold">Status</th>
|
| 62 |
+
<th class="px-4 py-3 text-left text-text opacity-70 font-semibold">User</th>
|
| 63 |
+
<th class="px-4 py-3 text-left text-text opacity-70 font-semibold">Addon Link</th>
|
| 64 |
+
<th class="px-4 py-3 text-left text-text opacity-70 font-semibold">Created</th>
|
| 65 |
+
<th class="px-4 py-3 text-left text-text opacity-70 font-semibold">Expires</th>
|
| 66 |
+
<th class="px-4 py-3 text-center text-text opacity-70 font-semibold">Actions</th>
|
| 67 |
+
</tr>
|
| 68 |
+
</thead>
|
| 69 |
+
<tbody id="token-table-body">
|
| 70 |
+
<tr>
|
| 71 |
+
<td colspan="6" class="text-center py-10 text-text opacity-40">Loading tokensβ¦</td>
|
| 72 |
+
</tr>
|
| 73 |
+
</tbody>
|
| 74 |
+
</table>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<!-- Pagination -->
|
| 79 |
+
<div class="flex items-center justify-between mt-4 text-sm text-text opacity-70" id="pagination-bar">
|
| 80 |
+
<span id="page-info"></span>
|
| 81 |
+
<div class="flex gap-2">
|
| 82 |
+
<button id="btn-prev" onclick="changePage(-1)"
|
| 83 |
+
class="px-3 py-1 rounded border border-gray-300 hover:bg-gray-100 disabled:opacity-30 transition">βΉ
|
| 84 |
+
Prev</button>
|
| 85 |
+
<span id="page-num" class="px-3 py-1 font-semibold"></span>
|
| 86 |
+
<button id="btn-next" onclick="changePage(1)"
|
| 87 |
+
class="px-3 py-1 rounded border border-gray-300 hover:bg-gray-100 disabled:opacity-30 transition">Next
|
| 88 |
+
βΊ</button>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<!-- Assign Plan Modal -->
|
| 94 |
+
<div id="assign-modal" class="fixed inset-0 bg-black bg-opacity-60 hidden items-center justify-center z-50"
|
| 95 |
+
style="display:none">
|
| 96 |
+
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 w-full max-w-md shadow-2xl mx-4">
|
| 97 |
+
<h3 class="text-lg font-bold mb-1 text-gray-800 dark:text-white">π Assign Subscription Plan</h3>
|
| 98 |
+
<p class="text-sm text-gray-500 mb-4" id="assign-user-label">User: β</p>
|
| 99 |
+
<input type="hidden" id="assign-user-id">
|
| 100 |
+
|
| 101 |
+
<div class="mb-4">
|
| 102 |
+
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Select a Plan</label>
|
| 103 |
+
<select id="assign-plan-select"
|
| 104 |
+
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-primary dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
| 105 |
+
<option value="">β Select plan β</option>
|
| 106 |
+
</select>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div class="mb-4">
|
| 110 |
+
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Or enter custom
|
| 111 |
+
days</label>
|
| 112 |
+
<input type="number" id="assign-custom-days" placeholder="e.g. 30" min="1"
|
| 113 |
+
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-primary dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
| 114 |
+
<p class="text-xs text-gray-400 mt-1">Custom days override plan selection above.</p>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div class="flex justify-end gap-3 mt-6">
|
| 118 |
+
<button onclick="closeAssignModal()"
|
| 119 |
+
class="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition text-sm">Cancel</button>
|
| 120 |
+
<button onclick="assignPlan()"
|
| 121 |
+
class="px-4 py-2 bg-primary text-white rounded-lg hover:opacity-80 transition text-sm font-semibold">β
|
| 122 |
+
Assign & Activate</button>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<script>
|
| 128 |
+
let allTokens = [];
|
| 129 |
+
let currentPage = 1;
|
| 130 |
+
|
| 131 |
+
function getFiltered() {
|
| 132 |
+
const q = document.getElementById('search-input').value.toLowerCase();
|
| 133 |
+
const status = document.getElementById('filter-status').value;
|
| 134 |
+
return allTokens.filter(t => {
|
| 135 |
+
const matchQ = !q ||
|
| 136 |
+
(t.user_name && t.user_name.toLowerCase().includes(q)) ||
|
| 137 |
+
(t.user_id && String(t.user_id).includes(q));
|
| 138 |
+
const matchStatus = status === 'all' || (status === 'active' && !t.is_expired) || (status === 'expired' && t.is_expired);
|
| 139 |
+
return matchQ && matchStatus;
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function renderPage() {
|
| 144 |
+
const filtered = getFiltered();
|
| 145 |
+
const pageSize = parseInt(document.getElementById('page-size').value);
|
| 146 |
+
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
| 147 |
+
if (currentPage > totalPages) currentPage = totalPages;
|
| 148 |
+
|
| 149 |
+
const start = (currentPage - 1) * pageSize;
|
| 150 |
+
const slice = filtered.slice(start, start + pageSize);
|
| 151 |
+
|
| 152 |
+
document.getElementById('page-info').textContent = `Showing ${start + 1}β${Math.min(start + pageSize, filtered.length)} of ${filtered.length}`;
|
| 153 |
+
document.getElementById('page-num').textContent = `Page ${currentPage} / ${totalPages}`;
|
| 154 |
+
document.getElementById('btn-prev').disabled = currentPage <= 1;
|
| 155 |
+
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
| 156 |
+
|
| 157 |
+
const tbody = document.getElementById('token-table-body');
|
| 158 |
+
if (!slice.length) {
|
| 159 |
+
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-10 text-text opacity-40">No tokens match your search.</td></tr>';
|
| 160 |
+
return;
|
| 161 |
+
}
|
| 162 |
+
tbody.innerHTML = slice.map(renderRow).join('');
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function renderRow(t) {
|
| 166 |
+
const statusBadge = t.is_expired
|
| 167 |
+
? '<span class="px-2 py-1 rounded-full text-xs font-semibold bg-red-100 text-red-600">π΄ Expired</span>'
|
| 168 |
+
: '<span class="px-2 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-700">π’ Active</span>';
|
| 169 |
+
|
| 170 |
+
// User column β show name or fallback to Telegram User (ID: X)
|
| 171 |
+
const displayName = t.user_name || (t.user_id ? `Telegram User` : 'Unknown');
|
| 172 |
+
const nameHtml = `<div class="font-medium text-text">${escHtml(displayName)}</div>`;
|
| 173 |
+
const idHtml = t.user_id ? `<div class="text-xs text-text opacity-50">ID: ${t.user_id}</div>` : '';
|
| 174 |
+
|
| 175 |
+
const addonLink = !t.has_token
|
| 176 |
+
? `<span class="px-2 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-700">β οΈ No Token Yet</span>
|
| 177 |
+
<div class="text-xs text-text opacity-40 italic mt-1">User hasn't generated addon token via bot.</div>`
|
| 178 |
+
: t.is_expired
|
| 179 |
+
? `<div>
|
| 180 |
+
<span class="px-2 py-1 rounded-full text-xs font-semibold bg-red-100 text-red-600">π΄ Subscription Expired</span>
|
| 181 |
+
<div class="text-xs text-text opacity-40 italic mt-1">${t.user_found ? 'Subscription inactive/expired' : 'No subscription record'}</div>
|
| 182 |
+
${t.addon_url ? `<button onclick="copyLink('${escHtml(t.addon_url)}')" class="text-xs text-text opacity-30 hover:opacity-60 underline mt-0.5">Copy URL (admin)</button>` : ''}
|
| 183 |
+
</div>`
|
| 184 |
+
: (t.addon_url
|
| 185 |
+
? `<button onclick="copyLink('${escHtml(t.addon_url)}')" class="text-primary hover:underline text-xs">π Copy Install Link</button>`
|
| 186 |
+
: 'β');
|
| 187 |
+
|
| 188 |
+
const expires = t.expires_at
|
| 189 |
+
? `<span class="${t.is_expired ? 'text-red-500 font-semibold' : ''}">${fmtDate(t.expires_at)}</span>`
|
| 190 |
+
: '<span class="text-red-400 text-xs italic">No expiry</span>';
|
| 191 |
+
|
| 192 |
+
const rowCls = t.is_expired ? 'bg-red-50 dark:bg-red-900/10' : '';
|
| 193 |
+
const safeName = escHtml(t.user_name || String(t.user_id || ''));
|
| 194 |
+
|
| 195 |
+
// --- Action buttons ---
|
| 196 |
+
let actionBtns = '';
|
| 197 |
+
if (t.user_id) {
|
| 198 |
+
// Full actions for linked tokens/subscribers
|
| 199 |
+
actionBtns = `
|
| 200 |
+
<button onclick="openAssignModal(${t.user_id}, '${safeName}')"
|
| 201 |
+
class="text-blue-500 hover:text-blue-700 text-xs px-2 py-1 rounded border border-blue-300 hover:bg-blue-50 transition mb-1 mr-1">
|
| 202 |
+
π
Assign
|
| 203 |
+
</button>
|
| 204 |
+
<button onclick="openSubModal(${t.user_id}, '${safeName}', 'extend')"
|
| 205 |
+
class="text-green-600 hover:text-green-800 text-xs px-2 py-1 rounded border border-green-300 hover:bg-green-50 transition mb-1 mr-1">
|
| 206 |
+
β Extend
|
| 207 |
+
</button>
|
| 208 |
+
<button onclick="openSubModal(${t.user_id}, '${safeName}', 'reduce')"
|
| 209 |
+
class="text-yellow-600 hover:text-yellow-800 text-xs px-2 py-1 rounded border border-yellow-300 hover:bg-yellow-50 transition mb-1 mr-1">
|
| 210 |
+
β Reduce
|
| 211 |
+
</button>
|
| 212 |
+
<button onclick="openSubModal(${t.user_id}, '${safeName}', 'delete')"
|
| 213 |
+
class="text-red-500 hover:text-red-700 text-xs px-2 py-1 rounded border border-red-300 hover:bg-red-50 transition mb-1 mr-1">
|
| 214 |
+
π« Revoke
|
| 215 |
+
</button>`;
|
| 216 |
+
} else if (t.token) {
|
| 217 |
+
// Orphan token β show Link User button
|
| 218 |
+
actionBtns = `
|
| 219 |
+
<button onclick="linkUser('${escHtml(t.token)}')"
|
| 220 |
+
class="text-purple-600 hover:text-purple-800 text-xs px-3 py-1 rounded border border-purple-300 hover:bg-purple-50 transition mb-1 font-semibold">
|
| 221 |
+
π Link User ID
|
| 222 |
+
</button>
|
| 223 |
+
<div class="text-xs text-text opacity-40 italic mt-0.5 w-full">Enter Telegram user_id to enable management</div>`;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
const revokeTokenBtn = t.token ? `
|
| 227 |
+
<button onclick="revokeToken('${escHtml(t.token)}')"
|
| 228 |
+
class="text-gray-500 hover:text-red-700 text-xs px-2 py-1 rounded border border-gray-300 hover:bg-red-50 transition mb-1">
|
| 229 |
+
π Del Token
|
| 230 |
+
</button>` : '';
|
| 231 |
+
|
| 232 |
+
return `<tr class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition ${rowCls}">
|
| 233 |
+
<td class="px-4 py-3">${statusBadge}</td>
|
| 234 |
+
<td class="px-4 py-3">${nameHtml}${idHtml}</td>
|
| 235 |
+
<td class="px-4 py-3">${addonLink}</td>
|
| 236 |
+
<td class="px-4 py-3 text-text opacity-70 text-xs">${t.created_at ? fmtDate(t.created_at) : 'β'}</td>
|
| 237 |
+
<td class="px-4 py-3 text-xs">${expires}</td>
|
| 238 |
+
<td class="px-4 py-3">
|
| 239 |
+
<div class="flex flex-wrap gap-1">${actionBtns}${revokeTokenBtn}</div>
|
| 240 |
+
</td>
|
| 241 |
+
</tr>`;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
function changePage(dir) {
|
| 245 |
+
currentPage += dir;
|
| 246 |
+
renderPage();
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
async function loadTokens() {
|
| 250 |
+
const tbody = document.getElementById('token-table-body');
|
| 251 |
+
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-10 text-text opacity-40">Loadingβ¦</td></tr>';
|
| 252 |
+
try {
|
| 253 |
+
const resp = await fetch('/api/admin/access/tokens');
|
| 254 |
+
const data = await resp.json();
|
| 255 |
+
allTokens = data.tokens || [];
|
| 256 |
+
|
| 257 |
+
document.getElementById('stat-total').textContent = allTokens.length;
|
| 258 |
+
document.getElementById('stat-active').textContent = allTokens.filter(t => !t.is_expired).length;
|
| 259 |
+
document.getElementById('stat-expired').textContent = allTokens.filter(t => t.is_expired).length;
|
| 260 |
+
|
| 261 |
+
currentPage = 1;
|
| 262 |
+
renderPage();
|
| 263 |
+
} catch (err) {
|
| 264 |
+
tbody.innerHTML = `<tr><td colspan="6" class="text-center py-10 text-red-500">Error: ${err.message}</td></tr>`;
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
async function loadPlans() {
|
| 269 |
+
try {
|
| 270 |
+
const resp = await fetch('/api/admin/subscriptions/plans');
|
| 271 |
+
const data = await resp.json();
|
| 272 |
+
const plans = data.plans || data || [];
|
| 273 |
+
const sel = document.getElementById('assign-plan-select');
|
| 274 |
+
sel.innerHTML = '<option value="">β Select a plan β</option>' +
|
| 275 |
+
plans.map(p => `<option value="${p.days}">${p.days} days${p.price ? ' β βΉ' + p.price : ''}</option>`).join('');
|
| 276 |
+
} catch (e) { console.warn('Could not load plans', e); }
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
function openAssignModal(userId, userName) {
|
| 280 |
+
document.getElementById('assign-user-id').value = userId;
|
| 281 |
+
document.getElementById('assign-user-label').textContent = `User: ${userName} (ID: ${userId})`;
|
| 282 |
+
document.getElementById('assign-custom-days').value = '';
|
| 283 |
+
document.getElementById('assign-plan-select').value = '';
|
| 284 |
+
const modal = document.getElementById('assign-modal');
|
| 285 |
+
modal.style.display = 'flex';
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
function closeAssignModal() {
|
| 289 |
+
document.getElementById('assign-modal').style.display = 'none';
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
async function assignPlan() {
|
| 293 |
+
const userId = parseInt(document.getElementById('assign-user-id').value);
|
| 294 |
+
const customDays = parseInt(document.getElementById('assign-custom-days').value) || 0;
|
| 295 |
+
const planDays = parseInt(document.getElementById('assign-plan-select').value) || 0;
|
| 296 |
+
const days = customDays || planDays;
|
| 297 |
+
|
| 298 |
+
if (!days || days < 1) { showToast('Please select a plan or enter custom days.', 'red'); return; }
|
| 299 |
+
|
| 300 |
+
try {
|
| 301 |
+
const resp = await fetch(`/api/admin/access/users/${userId}/assign-plan`, {
|
| 302 |
+
method: 'POST',
|
| 303 |
+
headers: { 'Content-Type': 'application/json' },
|
| 304 |
+
body: JSON.stringify({ days })
|
| 305 |
+
});
|
| 306 |
+
const data = await resp.json();
|
| 307 |
+
if (resp.ok) {
|
| 308 |
+
showToast(`β
Assigned ${days} days to user ${userId}`, 'green');
|
| 309 |
+
closeAssignModal();
|
| 310 |
+
loadTokens();
|
| 311 |
+
} else {
|
| 312 |
+
showToast(data.detail || 'Failed to assign plan.', 'red');
|
| 313 |
+
}
|
| 314 |
+
} catch (err) { showToast('Error: ' + err.message, 'red'); }
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
async function revokeToken(token) {
|
| 318 |
+
if (!confirm('Delete this token? The user will lose addon access immediately.')) return;
|
| 319 |
+
try {
|
| 320 |
+
const resp = await fetch(`/api/admin/access/tokens/${encodeURIComponent(token)}`, { method: 'DELETE' });
|
| 321 |
+
const data = await resp.json();
|
| 322 |
+
if (resp.ok) { showToast('Token deleted.', 'green'); loadTokens(); }
|
| 323 |
+
else showToast(data.detail || 'Failed.', 'red');
|
| 324 |
+
} catch (err) { showToast('Error: ' + err.message, 'red'); }
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
async function linkUser(token) {
|
| 328 |
+
const userId = prompt('Enter the Telegram User ID to link this token to:');
|
| 329 |
+
if (!userId || isNaN(parseInt(userId))) { showToast('Invalid user ID.', 'red'); return; }
|
| 330 |
+
try {
|
| 331 |
+
const resp = await fetch(`/api/admin/access/tokens/${encodeURIComponent(token)}/link-user`, {
|
| 332 |
+
method: 'PATCH',
|
| 333 |
+
headers: { 'Content-Type': 'application/json' },
|
| 334 |
+
body: JSON.stringify({ user_id: parseInt(userId) })
|
| 335 |
+
});
|
| 336 |
+
const data = await resp.json();
|
| 337 |
+
if (resp.ok) {
|
| 338 |
+
showToast(`β
Token linked to user ${userId}. Refreshingβ¦`, 'green');
|
| 339 |
+
loadTokens();
|
| 340 |
+
} else { showToast(data.detail || 'Failed to link.', 'red'); }
|
| 341 |
+
} catch (err) { showToast('Error: ' + err.message, 'red'); }
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
// Sub action modal (Extend / Reduce / Revoke)
|
| 345 |
+
function openSubModal(userId, userName, action) {
|
| 346 |
+
document.getElementById('sub-action-user-id').value = userId;
|
| 347 |
+
document.getElementById('sub-action-action').value = action;
|
| 348 |
+
const titles = { extend: 'β Extend Subscription', reduce: 'β Reduce Subscription', delete: 'π« Revoke Subscription' };
|
| 349 |
+
const descs = {
|
| 350 |
+
extend: `Add days to <b>${userName}</b>'s subscription.`,
|
| 351 |
+
reduce: `Remove days from <b>${userName}</b>'s subscription.`,
|
| 352 |
+
delete: `<b>Warning:</b> Revoke <b>${userName}</b>'s subscription entirely.`
|
| 353 |
+
};
|
| 354 |
+
document.getElementById('sub-action-title').textContent = titles[action];
|
| 355 |
+
document.getElementById('sub-action-desc').innerHTML = descs[action];
|
| 356 |
+
document.getElementById('sub-action-days-group').className = action === 'delete' ? 'hidden' : 'mb-4';
|
| 357 |
+
document.getElementById('sub-action-days').value = 30;
|
| 358 |
+
const btn = document.getElementById('sub-action-btn');
|
| 359 |
+
btn.className = `px-4 py-2 rounded-lg text-white text-sm font-semibold transition hover:opacity-80 ${action === 'delete' ? 'bg-red-500' : action === 'reduce' ? 'bg-yellow-500' : 'bg-green-600'}`;
|
| 360 |
+
document.getElementById('sub-action-modal').style.display = 'flex';
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function closeSubModal() { document.getElementById('sub-action-modal').style.display = 'none'; }
|
| 364 |
+
|
| 365 |
+
async function submitSubAction() {
|
| 366 |
+
const userId = parseInt(document.getElementById('sub-action-user-id').value);
|
| 367 |
+
const action = document.getElementById('sub-action-action').value;
|
| 368 |
+
const days = parseInt(document.getElementById('sub-action-days').value) || 0;
|
| 369 |
+
try {
|
| 370 |
+
const resp = await fetch(`/api/admin/subscriptions/users/${userId}/manage`, {
|
| 371 |
+
method: 'POST',
|
| 372 |
+
headers: { 'Content-Type': 'application/json' },
|
| 373 |
+
body: JSON.stringify({ action, days })
|
| 374 |
+
});
|
| 375 |
+
const data = await resp.json();
|
| 376 |
+
if (resp.ok) {
|
| 377 |
+
showToast(`β
${action.charAt(0).toUpperCase() + action.slice(1)}d subscription for user ${userId}`, 'green');
|
| 378 |
+
closeSubModal();
|
| 379 |
+
loadTokens();
|
| 380 |
+
} else { showToast(data.detail || 'Failed.', 'red'); }
|
| 381 |
+
} catch (err) { showToast('Error: ' + err.message, 'red'); }
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
async function copyLink(url) {
|
| 385 |
+
try { await navigator.clipboard.writeText(url); showToast('Link copied!', 'green'); }
|
| 386 |
+
catch { prompt('Copy this link:', url); }
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
function fmtDate(iso) {
|
| 390 |
+
if (!iso) return 'β';
|
| 391 |
+
const d = new Date(iso);
|
| 392 |
+
return d.toLocaleDateString('en-IN', { timeZone: 'Asia/Kolkata', day: '2-digit', month: 'short', year: 'numeric' })
|
| 393 |
+
+ ' ' + d.toLocaleTimeString('en-IN', { timeZone: 'Asia/Kolkata', hour: '2-digit', minute: '2-digit' });
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
function escHtml(s) {
|
| 397 |
+
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
function showToast(msg, color) {
|
| 401 |
+
const t = document.createElement('div');
|
| 402 |
+
t.textContent = msg;
|
| 403 |
+
t.className = `fixed bottom-6 right-6 px-4 py-3 rounded-xl shadow-lg text-white text-sm z-[200] bg-${color}-500 transition-all`;
|
| 404 |
+
document.body.appendChild(t);
|
| 405 |
+
setTimeout(() => t.remove(), 3000);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
// Close modal on backdrop click
|
| 409 |
+
document.getElementById('assign-modal').addEventListener('click', function (e) {
|
| 410 |
+
if (e.target === this) closeAssignModal();
|
| 411 |
+
});
|
| 412 |
+
|
| 413 |
+
loadPlans();
|
| 414 |
+
loadTokens();
|
| 415 |
+
</script>
|
| 416 |
+
|
| 417 |
+
<!-- Sub Action Modal (Extend / Reduce / Revoke) -->
|
| 418 |
+
<div id="sub-action-modal" class="fixed inset-0 bg-black bg-opacity-60 items-center justify-center z-50 mx-4"
|
| 419 |
+
style="display:none">
|
| 420 |
+
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 w-full max-w-md shadow-2xl mx-auto mt-24">
|
| 421 |
+
<h3 class="text-lg font-bold mb-1 text-gray-800 dark:text-white" id="sub-action-title">Manage Subscription</h3>
|
| 422 |
+
<p class="text-sm text-gray-500 mb-4" id="sub-action-desc"></p>
|
| 423 |
+
<input type="hidden" id="sub-action-user-id">
|
| 424 |
+
<input type="hidden" id="sub-action-action">
|
| 425 |
+
<div id="sub-action-days-group" class="mb-4">
|
| 426 |
+
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Number of Days</label>
|
| 427 |
+
<input type="number" id="sub-action-days" value="30" min="1"
|
| 428 |
+
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-primary dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
| 429 |
+
</div>
|
| 430 |
+
<div class="flex justify-end gap-3 mt-4">
|
| 431 |
+
<button onclick="closeSubModal()"
|
| 432 |
+
class="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition text-sm">Cancel</button>
|
| 433 |
+
<button id="sub-action-btn" onclick="submitSubAction()"
|
| 434 |
+
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:opacity-80 transition text-sm font-semibold">Confirm</button>
|
| 435 |
+
</div>
|
| 436 |
+
</div>
|
| 437 |
+
</div>
|
| 438 |
+
|
| 439 |
+
{% endblock %}
|
Backend/fastapi/templates/base.html
CHANGED
|
@@ -65,40 +65,44 @@
|
|
| 65 |
<h1 class="text-2xl font-bold text-primary">Telegram Stremio</h1>
|
| 66 |
{% if current_user %}
|
| 67 |
<div class="hidden md:flex space-x-4">
|
| 68 |
-
<a href="/"
|
| 69 |
-
|
| 70 |
-
<a href="/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
</div>
|
| 72 |
{% endif %}
|
| 73 |
</div>
|
| 74 |
|
| 75 |
<div class="flex items-center space-x-4">
|
| 76 |
<!-- Theme Selector -->
|
| 77 |
-
<
|
| 78 |
-
<
|
| 79 |
-
|
| 80 |
-
class="theme-card border border-gray-300
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
| 88 |
|
| 89 |
{% if current_user %}
|
| 90 |
<div class="flex items-center space-x-4">
|
| 91 |
<span class="text-sm theme-text-secondary">{{ current_user }}</span>
|
| 92 |
<a href="/logout"
|
| 93 |
-
|
| 94 |
-
Logout
|
| 95 |
-
</a>
|
| 96 |
</div>
|
| 97 |
{% else %}
|
| 98 |
<a href="/login"
|
| 99 |
-
|
| 100 |
-
Login
|
| 101 |
-
</a>
|
| 102 |
{% endif %}
|
| 103 |
</div>
|
| 104 |
</div>
|
|
@@ -116,13 +120,14 @@
|
|
| 116 |
<div class="flex justify-between items-center">
|
| 117 |
<p class="theme-text-secondary text-sm">Β© 2025 Telegram Stremio. All rights reserved.</p>
|
| 118 |
<div class="flex space-x-4">
|
| 119 |
-
<a href="/status"
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
</div>
|
| 124 |
</div>
|
| 125 |
</div>
|
| 126 |
</footer>
|
| 127 |
</body>
|
| 128 |
-
|
|
|
|
|
|
| 65 |
<h1 class="text-2xl font-bold text-primary">Telegram Stremio</h1>
|
| 66 |
{% if current_user %}
|
| 67 |
<div class="hidden md:flex space-x-4">
|
| 68 |
+
<a href="/"
|
| 69 |
+
class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Dashboard</a>
|
| 70 |
+
<a href="/media/manage"
|
| 71 |
+
class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Media</a>
|
| 72 |
+
<a href="/admin/dashboard"
|
| 73 |
+
class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Admin</a>
|
| 74 |
+
<a href="/admin/subscriptions"
|
| 75 |
+
class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Sub Manage</a>
|
| 76 |
+
<a href="/admin/access"
|
| 77 |
+
class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Access</a>
|
| 78 |
</div>
|
| 79 |
{% endif %}
|
| 80 |
</div>
|
| 81 |
|
| 82 |
<div class="flex items-center space-x-4">
|
| 83 |
<!-- Theme Selector -->
|
| 84 |
+
<div class="relative">
|
| 85 |
+
<form method="post" action="/set-theme" id="theme-form">
|
| 86 |
+
<select name="theme" onchange="document.getElementById('theme-form').submit()"
|
| 87 |
+
class="theme-card border border-gray-300 rounded-md px-3 py-2 text-sm">
|
| 88 |
+
{% for theme_key, theme_data in themes.items() %}
|
| 89 |
+
<option value="{{ theme_key }}" {% if current_theme==theme_key %}selected{% endif %}>
|
| 90 |
+
{{ theme_data.name }}
|
| 91 |
+
</option>
|
| 92 |
+
{% endfor %}
|
| 93 |
+
</select>
|
| 94 |
+
</form>
|
| 95 |
+
</div>
|
| 96 |
|
| 97 |
{% if current_user %}
|
| 98 |
<div class="flex items-center space-x-4">
|
| 99 |
<span class="text-sm theme-text-secondary">{{ current_user }}</span>
|
| 100 |
<a href="/logout"
|
| 101 |
+
class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 transition-colors">Logout</a>
|
|
|
|
|
|
|
| 102 |
</div>
|
| 103 |
{% else %}
|
| 104 |
<a href="/login"
|
| 105 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors">Login</a>
|
|
|
|
|
|
|
| 106 |
{% endif %}
|
| 107 |
</div>
|
| 108 |
</div>
|
|
|
|
| 120 |
<div class="flex justify-between items-center">
|
| 121 |
<p class="theme-text-secondary text-sm">Β© 2025 Telegram Stremio. All rights reserved.</p>
|
| 122 |
<div class="flex space-x-4">
|
| 123 |
+
<a href="/status"
|
| 124 |
+
class="theme-text-secondary hover:text-primary text-sm transition-colors">Status</a>
|
| 125 |
+
<a href="/stremio" class="theme-text-secondary hover:text-primary text-sm transition-colors">Stremio
|
| 126 |
+
Guide</a>
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
</div>
|
| 130 |
</footer>
|
| 131 |
</body>
|
| 132 |
+
|
| 133 |
+
</html>
|
Backend/fastapi/templates/dashboard.html
CHANGED
|
@@ -669,50 +669,51 @@
|
|
| 669 |
const response = await fetch('/stream/stats');
|
| 670 |
if (response.ok) {
|
| 671 |
const data = await response.json();
|
| 672 |
-
const
|
| 673 |
const container = document.getElementById('active-streams-container');
|
|
|
|
|
|
|
| 674 |
|
| 675 |
-
if (
|
| 676 |
-
|
| 677 |
-
container.innerHTML = `<div class="p-8 text-center bg-gray-50 border border-dashed rounded-lg">
|
| 678 |
<i class="fas fa-video-slash text-gray-300 text-3xl mb-3"></i>
|
| 679 |
<p class="text-gray-500 font-medium">No active streams at the moment</p>
|
| 680 |
<p class="text-xs text-gray-400 mt-1">Users are not currently watching any media.</p>
|
| 681 |
</div>`;
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
|
| 712 |
-
|
| 713 |
-
|
| 714 |
|
| 715 |
-
|
| 716 |
<div class="p-4 bg-gray-50 rounded-lg border-l-4 border-green-500">
|
| 717 |
<div class="flex justify-between items-start mb-3">
|
| 718 |
<div class="flex items-center gap-2 max-w-[70%]">
|
|
@@ -797,13 +798,13 @@
|
|
| 797 |
` : ''}
|
| 798 |
</div>
|
| 799 |
`;
|
| 800 |
-
|
| 801 |
-
}
|
| 802 |
}
|
| 803 |
}
|
| 804 |
-
} catch (error) {
|
| 805 |
-
console.error("Failed to update active streams:", error);
|
| 806 |
}
|
|
|
|
|
|
|
|
|
|
| 807 |
}
|
| 808 |
|
| 809 |
// Auto-refresh every 2 minutes
|
|
|
|
| 669 |
const response = await fetch('/stream/stats');
|
| 670 |
if (response.ok) {
|
| 671 |
const data = await response.json();
|
| 672 |
+
const allStreams = data.active_streams || [];
|
| 673 |
const container = document.getElementById('active-streams-container');
|
| 674 |
+
// Only show streams that are actively sending data
|
| 675 |
+
const liveStreams = allStreams.filter(s => s.status === 'active' && (s.total_bytes || 0) > 0);
|
| 676 |
|
| 677 |
+
if (liveStreams.length === 0) {
|
| 678 |
+
container.innerHTML = `<div class="p-8 text-center bg-gray-50 border border-dashed rounded-lg">
|
|
|
|
| 679 |
<i class="fas fa-video-slash text-gray-300 text-3xl mb-3"></i>
|
| 680 |
<p class="text-gray-500 font-medium">No active streams at the moment</p>
|
| 681 |
<p class="text-xs text-gray-400 mt-1">Users are not currently watching any media.</p>
|
| 682 |
</div>`;
|
| 683 |
+
} else {
|
| 684 |
+
const escapeHtml = (unsafe) => {
|
| 685 |
+
if (!unsafe) return '';
|
| 686 |
+
return unsafe
|
| 687 |
+
.toString()
|
| 688 |
+
.replace(/&/g, "&")
|
| 689 |
+
.replace(/</g, "<")
|
| 690 |
+
.replace(/>/g, ">")
|
| 691 |
+
.replace(/"/g, """)
|
| 692 |
+
.replace(/'/g, "'");
|
| 693 |
+
};
|
| 694 |
+
|
| 695 |
+
const formatBytesStr = (bytes) => {
|
| 696 |
+
if (bytes === 0) return '0 B';
|
| 697 |
+
const k = 1024;
|
| 698 |
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
| 699 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 700 |
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
| 701 |
+
};
|
| 702 |
+
|
| 703 |
+
container.innerHTML = liveStreams.map(stream => {
|
| 704 |
+
let statusColor = 'bg-red-100 text-red-800';
|
| 705 |
+
let statusPulse = 'hidden';
|
| 706 |
+
if (stream.status === 'active') {
|
| 707 |
+
statusColor = 'bg-green-100 text-green-800';
|
| 708 |
+
statusPulse = '';
|
| 709 |
+
} else if (stream.status === 'cancelled') {
|
| 710 |
+
statusColor = 'bg-yellow-100 text-yellow-800';
|
| 711 |
+
}
|
| 712 |
|
| 713 |
+
const streamIdStr = stream.stream_id ? stream.stream_id.substring(0, 8) + '...' : 'Unknown';
|
| 714 |
+
const titleStr = (stream.meta && stream.meta.title) ? stream.meta.title : (stream.title || 'Unknown Stream');
|
| 715 |
|
| 716 |
+
return `
|
| 717 |
<div class="p-4 bg-gray-50 rounded-lg border-l-4 border-green-500">
|
| 718 |
<div class="flex justify-between items-start mb-3">
|
| 719 |
<div class="flex items-center gap-2 max-w-[70%]">
|
|
|
|
| 798 |
` : ''}
|
| 799 |
</div>
|
| 800 |
`;
|
| 801 |
+
}).join('');
|
|
|
|
| 802 |
}
|
| 803 |
}
|
|
|
|
|
|
|
| 804 |
}
|
| 805 |
+
} catch (error) {
|
| 806 |
+
console.error("Failed to update active streams:", error);
|
| 807 |
+
}
|
| 808 |
}
|
| 809 |
|
| 810 |
// Auto-refresh every 2 minutes
|
Backend/fastapi/templates/subscriptions_manage.html
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Subscription Management - Telegram Stremio{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 7 |
+
<div class="mb-8 flex justify-between items-center">
|
| 8 |
+
<div>
|
| 9 |
+
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Subscription Management</h1>
|
| 10 |
+
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Manage subscription plans and view or edit active
|
| 11 |
+
users.</p>
|
| 12 |
+
</div>
|
| 13 |
+
</div>
|
| 14 |
+
|
| 15 |
+
<!-- Subscription Plans Section -->
|
| 16 |
+
<div class="mb-10">
|
| 17 |
+
<div class="flex items-center justify-between mb-4">
|
| 18 |
+
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Subscription Plans</h2>
|
| 19 |
+
<button onclick="openPlanModal()"
|
| 20 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors shadow-sm flex items-center">
|
| 21 |
+
<i class="fas fa-plus mr-2"></i> Add Plan
|
| 22 |
+
</button>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-6" id="plans-container">
|
| 25 |
+
<div class="col-span-3 text-center text-gray-500 py-8">
|
| 26 |
+
<i class="fas fa-spinner fa-spin mr-2"></i> Loading plans...
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<!-- User Subscriptions Section -->
|
| 32 |
+
<div class="mb-10">
|
| 33 |
+
<div class="flex items-center justify-between mb-3">
|
| 34 |
+
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">User Subscriptions</h2>
|
| 35 |
+
<button onclick="fetchSubscribers()"
|
| 36 |
+
class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm font-medium transition-colors shadow-sm flex items-center">
|
| 37 |
+
<i class="fas fa-sync-alt mr-2"></i> Refresh
|
| 38 |
+
</button>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<!-- Search & Filter bar -->
|
| 42 |
+
<div class="flex flex-wrap gap-3 mb-3 items-center">
|
| 43 |
+
<input id="sub-search" type="text" placeholder="π Search by name, username or IDβ¦"
|
| 44 |
+
class="flex-1 min-w-[200px] border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-primary bg-white dark:bg-gray-800 text-text"
|
| 45 |
+
oninput="renderSubPage()">
|
| 46 |
+
<select id="sub-filter"
|
| 47 |
+
class="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none bg-white dark:bg-gray-800 text-text"
|
| 48 |
+
onchange="renderSubPage()">
|
| 49 |
+
<option value="all">All Status</option>
|
| 50 |
+
<option value="active">π’ Active</option>
|
| 51 |
+
<option value="expired">π΄ Expired</option>
|
| 52 |
+
</select>
|
| 53 |
+
<select id="sub-page-size"
|
| 54 |
+
class="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none bg-white dark:bg-gray-800 text-text"
|
| 55 |
+
onchange="subPage=1; renderSubPage()">
|
| 56 |
+
<option value="10">10 / page</option>
|
| 57 |
+
<option value="25">25 / page</option>
|
| 58 |
+
<option value="50">50 / page</option>
|
| 59 |
+
</select>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div class="theme-card border rounded-xl overflow-hidden shadow-sm">
|
| 63 |
+
<div class="overflow-x-auto">
|
| 64 |
+
<table class="w-full text-sm text-left">
|
| 65 |
+
<thead class="text-xs uppercase bg-gray-50 dark:bg-gray-800 dark:text-gray-300 border-b">
|
| 66 |
+
<tr>
|
| 67 |
+
<th class="px-6 py-4 font-semibold">User Details</th>
|
| 68 |
+
<th class="px-6 py-4 font-semibold text-center">Status</th>
|
| 69 |
+
<th class="px-6 py-4 font-semibold text-center">Expiry Date</th>
|
| 70 |
+
<th class="px-6 py-4 font-semibold text-right">Actions</th>
|
| 71 |
+
</tr>
|
| 72 |
+
</thead>
|
| 73 |
+
<tbody id="subscribers-tbody" class="divide-y divide-gray-100 dark:divide-gray-800">
|
| 74 |
+
<tr>
|
| 75 |
+
<td colspan="4" class="px-6 py-8 text-center text-gray-500"><i
|
| 76 |
+
class="fas fa-spinner fa-spin mr-2"></i> Loading...</td>
|
| 77 |
+
</tr>
|
| 78 |
+
</tbody>
|
| 79 |
+
</table>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<!-- Pagination -->
|
| 84 |
+
<div class="flex items-center justify-between mt-3 text-sm text-text opacity-70" id="sub-pagination">
|
| 85 |
+
<span id="sub-page-info"></span>
|
| 86 |
+
<div class="flex gap-2">
|
| 87 |
+
<button id="sub-btn-prev" onclick="subChangePage(-1)"
|
| 88 |
+
class="px-3 py-1 rounded border border-gray-300 hover:bg-gray-100 transition">βΉ Prev</button>
|
| 89 |
+
<span id="sub-page-num" class="px-3 py-1 font-semibold"></span>
|
| 90 |
+
<button id="sub-btn-next" onclick="subChangePage(1)"
|
| 91 |
+
class="px-3 py-1 rounded border border-gray-300 hover:bg-gray-100 transition">Next βΊ</button>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<!-- Plan Modal -->
|
| 98 |
+
<div id="plan-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
|
| 99 |
+
<div class="relative top-20 mx-auto p-5 border shadow-lg rounded-md theme-card sm:w-11/12 md:max-w-md">
|
| 100 |
+
<div class="mt-3 text-center">
|
| 101 |
+
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-2" id="plan-modal-title">Add New
|
| 102 |
+
Plan</h3>
|
| 103 |
+
<div class="mt-2 text-left">
|
| 104 |
+
<form id="plan-form" onsubmit="submitPlanForm(event)">
|
| 105 |
+
<input type="hidden" id="plan-id">
|
| 106 |
+
<div class="mb-4">
|
| 107 |
+
<label class="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2">Duration
|
| 108 |
+
(Days)</label>
|
| 109 |
+
<input type="number" id="plan-days"
|
| 110 |
+
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none dark:bg-gray-700 dark:text-white dark:border-gray-600"
|
| 111 |
+
required min="1">
|
| 112 |
+
</div>
|
| 113 |
+
<div class="mb-4">
|
| 114 |
+
<label class="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2">Price (βΉ)</label>
|
| 115 |
+
<input type="number" id="plan-price"
|
| 116 |
+
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none dark:bg-gray-700 dark:text-white dark:border-gray-600"
|
| 117 |
+
required min="0" step="0.01">
|
| 118 |
+
</div>
|
| 119 |
+
<div class="flex justify-end gap-2 mt-6">
|
| 120 |
+
<button type="button" onclick="closePlanModal()"
|
| 121 |
+
class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded shadow">Cancel</button>
|
| 122 |
+
<button type="submit"
|
| 123 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded shadow">Save Plan</button>
|
| 124 |
+
</div>
|
| 125 |
+
</form>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<!-- User Action Modal -->
|
| 132 |
+
<div id="user-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
|
| 133 |
+
<div class="relative top-20 mx-auto p-5 border shadow-lg rounded-md theme-card sm:w-11/12 md:max-w-md">
|
| 134 |
+
<div class="mt-3 text-center">
|
| 135 |
+
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-2" id="user-modal-title">Manage
|
| 136 |
+
User</h3>
|
| 137 |
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4" id="user-modal-desc"></div>
|
| 138 |
+
<div class="mt-2 text-left">
|
| 139 |
+
<form id="user-form" onsubmit="submitUserForm(event)">
|
| 140 |
+
<input type="hidden" id="manage-user-id">
|
| 141 |
+
<input type="hidden" id="manage-action">
|
| 142 |
+
<div class="mb-4" id="days-input-group">
|
| 143 |
+
<label class="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2">Days to <span
|
| 144 |
+
id="days-action-label"></span></label>
|
| 145 |
+
<input type="number" id="manage-days"
|
| 146 |
+
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 focus:outline-none dark:bg-gray-700 dark:text-white dark:border-gray-600"
|
| 147 |
+
min="1" value="30">
|
| 148 |
+
</div>
|
| 149 |
+
<div class="flex justify-end gap-2 mt-6">
|
| 150 |
+
<button type="button" onclick="closeUserModal()"
|
| 151 |
+
class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded shadow">Cancel</button>
|
| 152 |
+
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded shadow"
|
| 153 |
+
id="btn-user-save">Confirm</button>
|
| 154 |
+
</div>
|
| 155 |
+
</form>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
<script>
|
| 162 |
+
// ---- Plans ----
|
| 163 |
+
async function fetchPlans() {
|
| 164 |
+
try {
|
| 165 |
+
const res = await fetch('/api/admin/subscriptions/plans');
|
| 166 |
+
const container = document.getElementById('plans-container');
|
| 167 |
+
if (res.ok) {
|
| 168 |
+
const data = await res.json();
|
| 169 |
+
const plans = data.data || data.plans || [];
|
| 170 |
+
if (!plans.length) {
|
| 171 |
+
container.innerHTML = '<div class="col-span-3 text-center text-gray-500 py-8">No plans found. Create one.</div>';
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
container.innerHTML = plans.map(p => `
|
| 175 |
+
<div class="theme-card rounded-xl border p-6 shadow-sm relative group">
|
| 176 |
+
<div class="absolute right-4 top-4 hidden group-hover:flex gap-2">
|
| 177 |
+
<button onclick="openPlanModal('${p._id}', ${p.days}, ${p.price})" class="text-blue-500 hover:text-blue-700"><i class="fas fa-edit"></i></button>
|
| 178 |
+
<button onclick="deletePlan('${p._id}')" class="text-red-500 hover:text-red-700"><i class="fas fa-trash"></i></button>
|
| 179 |
+
</div>
|
| 180 |
+
<div class="flex items-center">
|
| 181 |
+
<div class="flex-shrink-0 bg-yellow-100 dark:bg-yellow-900/30 p-3 rounded-lg">
|
| 182 |
+
<i class="fas fa-calendar-alt text-xl text-yellow-600 dark:text-yellow-400"></i>
|
| 183 |
+
</div>
|
| 184 |
+
<div class="ml-4">
|
| 185 |
+
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">${p.days} Days Plan</h3>
|
| 186 |
+
<p class="text-2xl font-bold text-gray-900 dark:text-white">βΉ${p.price}</p>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
</div>`).join('');
|
| 190 |
+
}
|
| 191 |
+
} catch (err) { console.error(err); }
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function openPlanModal(id = '', days = '', price = '') {
|
| 195 |
+
document.getElementById('plan-id').value = id;
|
| 196 |
+
document.getElementById('plan-days').value = days;
|
| 197 |
+
document.getElementById('plan-price').value = price;
|
| 198 |
+
document.getElementById('plan-modal-title').innerText = id ? 'Edit Plan' : 'Add New Plan';
|
| 199 |
+
document.getElementById('plan-modal').classList.remove('hidden');
|
| 200 |
+
}
|
| 201 |
+
function closePlanModal() { document.getElementById('plan-modal').classList.add('hidden'); }
|
| 202 |
+
|
| 203 |
+
async function submitPlanForm(e) {
|
| 204 |
+
e.preventDefault();
|
| 205 |
+
const id = document.getElementById('plan-id').value;
|
| 206 |
+
const payload = { days: parseInt(document.getElementById('plan-days').value), price: parseFloat(document.getElementById('plan-price').value) };
|
| 207 |
+
const res = await fetch(id ? `/api/admin/subscriptions/plans/${id}` : '/api/admin/subscriptions/plans', {
|
| 208 |
+
method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
|
| 209 |
+
});
|
| 210 |
+
if (res.ok) { closePlanModal(); fetchPlans(); } else alert('Failed to save plan.');
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
async function deletePlan(id) {
|
| 214 |
+
if (!confirm('Delete this plan?')) return;
|
| 215 |
+
const res = await fetch(`/api/admin/subscriptions/plans/${id}`, { method: 'DELETE' });
|
| 216 |
+
if (res.ok) fetchPlans(); else alert('Failed to delete.');
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// ---- Subscribers with Search + Pagination ----
|
| 220 |
+
let allUsers = [];
|
| 221 |
+
let subPage = 1;
|
| 222 |
+
|
| 223 |
+
function getSubFiltered() {
|
| 224 |
+
const q = document.getElementById('sub-search').value.toLowerCase();
|
| 225 |
+
const status = document.getElementById('sub-filter').value;
|
| 226 |
+
return allUsers.filter(u => {
|
| 227 |
+
const name = ((u.first_name || '') + ' ' + (u.username || '') + ' ' + (u._id || '')).toLowerCase();
|
| 228 |
+
const matchQ = !q || name.includes(q);
|
| 229 |
+
const matchStatus = status === 'all' || u.subscription_status === status;
|
| 230 |
+
return matchQ && matchStatus;
|
| 231 |
+
});
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
function renderSubPage() {
|
| 235 |
+
const filtered = getSubFiltered();
|
| 236 |
+
const pageSize = parseInt(document.getElementById('sub-page-size').value);
|
| 237 |
+
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
| 238 |
+
if (subPage > totalPages) subPage = totalPages;
|
| 239 |
+
const start = (subPage - 1) * pageSize;
|
| 240 |
+
const slice = filtered.slice(start, start + pageSize);
|
| 241 |
+
|
| 242 |
+
document.getElementById('sub-page-info').textContent = `Showing ${start + 1}β${Math.min(start + pageSize, filtered.length)} of ${filtered.length}`;
|
| 243 |
+
document.getElementById('sub-page-num').textContent = `Page ${subPage} / ${totalPages}`;
|
| 244 |
+
document.getElementById('sub-btn-prev').disabled = subPage <= 1;
|
| 245 |
+
document.getElementById('sub-btn-next').disabled = subPage >= totalPages;
|
| 246 |
+
|
| 247 |
+
const tbody = document.getElementById('subscribers-tbody');
|
| 248 |
+
if (!slice.length) {
|
| 249 |
+
tbody.innerHTML = '<tr><td colspan="4" class="px-6 py-8 text-center text-gray-500">No users match your search.</td></tr>';
|
| 250 |
+
return;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
tbody.innerHTML = slice.map(u => {
|
| 254 |
+
const statusColor = u.subscription_status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800';
|
| 255 |
+
let expiry = 'N/A';
|
| 256 |
+
if (u.subscription_expiry) {
|
| 257 |
+
const d = new Date(u.subscription_expiry);
|
| 258 |
+
expiry = d.toLocaleDateString('en-IN', { timeZone: 'Asia/Kolkata', day: '2-digit', month: 'short', year: 'numeric' }) +
|
| 259 |
+
' ' + d.toLocaleTimeString('en-IN', { timeZone: 'Asia/Kolkata', hour: '2-digit', minute: '2-digit' });
|
| 260 |
+
}
|
| 261 |
+
let name = u.first_name || '';
|
| 262 |
+
if (u.username) name += (name ? ' ' : '') + `(@${u.username})`;
|
| 263 |
+
if (!name) name = `User ${u._id}`;
|
| 264 |
+
const safeName = name.replace(/'/g, "\\'");
|
| 265 |
+
return `<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
| 266 |
+
<td class="px-6 py-4">
|
| 267 |
+
<div class="font-medium text-gray-900 dark:text-gray-100">${name}</div>
|
| 268 |
+
<div class="text-xs text-gray-500">ID: ${u._id}</div>
|
| 269 |
+
</td>
|
| 270 |
+
<td class="px-6 py-4 text-center">
|
| 271 |
+
<span class="px-2 py-1 rounded ${statusColor} text-xs font-bold uppercase tracking-wider">${u.subscription_status}</span>
|
| 272 |
+
</td>
|
| 273 |
+
<td class="px-6 py-4 text-center text-sm">${expiry}</td>
|
| 274 |
+
<td class="px-6 py-4 text-right">
|
| 275 |
+
<div class="flex justify-end gap-2 flex-wrap">
|
| 276 |
+
<button onclick="openUserModal(${u._id}, '${safeName}', 'extend')"
|
| 277 |
+
class="bg-green-50 text-green-600 hover:bg-green-100 px-2 py-1 rounded text-xs font-medium border border-green-200">Extend</button>
|
| 278 |
+
<button onclick="openUserModal(${u._id}, '${safeName}', 'reduce')"
|
| 279 |
+
class="bg-yellow-50 text-yellow-600 hover:bg-yellow-100 px-2 py-1 rounded text-xs font-medium border border-yellow-200">Reduce</button>
|
| 280 |
+
<button onclick="openUserModal(${u._id}, '${safeName}', 'delete')"
|
| 281 |
+
class="bg-red-50 text-red-600 hover:bg-red-100 px-2 py-1 rounded text-xs font-medium border border-red-200">Revoke</button>
|
| 282 |
+
</div>
|
| 283 |
+
</td>
|
| 284 |
+
</tr>`;
|
| 285 |
+
}).join('');
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
function subChangePage(dir) { subPage += dir; renderSubPage(); }
|
| 289 |
+
|
| 290 |
+
async function fetchSubscribers() {
|
| 291 |
+
document.getElementById('subscribers-tbody').innerHTML =
|
| 292 |
+
'<tr><td colspan="4" class="px-6 py-8 text-center text-gray-500"><i class="fas fa-spinner fa-spin mr-2"></i> Loading...</td></tr>';
|
| 293 |
+
try {
|
| 294 |
+
const res = await fetch('/api/admin/subscriptions/users');
|
| 295 |
+
if (res.ok) {
|
| 296 |
+
const data = await res.json();
|
| 297 |
+
allUsers = data.data || [];
|
| 298 |
+
subPage = 1;
|
| 299 |
+
renderSubPage();
|
| 300 |
+
}
|
| 301 |
+
} catch (err) { console.error(err); }
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
function openUserModal(userId, userName, action) {
|
| 305 |
+
document.getElementById('manage-user-id').value = userId;
|
| 306 |
+
document.getElementById('manage-action').value = action;
|
| 307 |
+
let title = '', desc = '', btnColor = 'bg-blue-600 hover:bg-blue-700', showDays = true;
|
| 308 |
+
if (action === 'extend') {
|
| 309 |
+
title = 'Extend Subscription'; desc = `Add days to <b>${userName}</b>'s subscription.`;
|
| 310 |
+
document.getElementById('days-action-label').innerText = 'Add';
|
| 311 |
+
} else if (action === 'reduce') {
|
| 312 |
+
title = 'Reduce Subscription'; desc = `Remove days from <b>${userName}</b>'s subscription.`;
|
| 313 |
+
document.getElementById('days-action-label').innerText = 'Subtract'; btnColor = 'bg-yellow-600 hover:bg-yellow-700';
|
| 314 |
+
} else {
|
| 315 |
+
title = 'Revoke Subscription'; desc = `<b>Warning:</b> This will revoke <b>${userName}</b>'s subscription entirely.`;
|
| 316 |
+
showDays = false; btnColor = 'bg-red-600 hover:bg-red-700';
|
| 317 |
+
}
|
| 318 |
+
document.getElementById('user-modal-title').innerText = title;
|
| 319 |
+
document.getElementById('user-modal-desc').innerHTML = desc;
|
| 320 |
+
document.getElementById('days-input-group').className = showDays ? 'mb-4' : 'hidden';
|
| 321 |
+
document.getElementById('btn-user-save').className = `${btnColor} text-white px-4 py-2 rounded shadow`;
|
| 322 |
+
if (!showDays) { document.getElementById('manage-days').value = 0; document.getElementById('manage-days').removeAttribute('required'); }
|
| 323 |
+
else document.getElementById('manage-days').setAttribute('required', 'required');
|
| 324 |
+
document.getElementById('user-modal').classList.remove('hidden');
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function closeUserModal() { document.getElementById('user-modal').classList.add('hidden'); }
|
| 328 |
+
|
| 329 |
+
async function submitUserForm(e) {
|
| 330 |
+
e.preventDefault();
|
| 331 |
+
const userId = document.getElementById('manage-user-id').value;
|
| 332 |
+
const action = document.getElementById('manage-action').value;
|
| 333 |
+
const days = document.getElementById('manage-days').value;
|
| 334 |
+
const res = await fetch(`/api/admin/subscriptions/users/${userId}/manage`, {
|
| 335 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 336 |
+
body: JSON.stringify({ action, days: parseInt(days) || 0 })
|
| 337 |
+
});
|
| 338 |
+
if (res.ok) { closeUserModal(); fetchSubscribers(); } else alert('Failed to update user.');
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
document.addEventListener('DOMContentLoaded', () => { fetchPlans(); fetchSubscribers(); });
|
| 342 |
+
</script>
|
| 343 |
+
{% endblock %}
|
Backend/helper/database.py
CHANGED
|
@@ -78,6 +78,217 @@ class Database:
|
|
| 78 |
upsert=True
|
| 79 |
)
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
# -------------------------------
|
| 83 |
# Helper Methods for Repeated Logic
|
|
@@ -938,13 +1149,20 @@ class Database:
|
|
| 938 |
# API Token Methods
|
| 939 |
# -------------------------------
|
| 940 |
|
| 941 |
-
async def add_api_token(self, name: str, daily_limit_gb: float = None, monthly_limit_gb: float = None) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 942 |
alphabet = string.ascii_letters + string.digits
|
| 943 |
token = ''.join(secrets.choice(alphabet) for _ in range(32))
|
| 944 |
|
| 945 |
token_doc = {
|
| 946 |
"name": name,
|
| 947 |
"token": token,
|
|
|
|
| 948 |
"created_at": datetime.utcnow(),
|
| 949 |
"limits": {
|
| 950 |
"daily_limit_gb": daily_limit_gb if daily_limit_gb else 0,
|
|
@@ -973,6 +1191,14 @@ class Database:
|
|
| 973 |
result = await self.dbs["tracking"]["api_tokens"].delete_one({"token": token})
|
| 974 |
return result.deleted_count > 0
|
| 975 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 976 |
async def update_token_usage(self, token: str, bytes_delta: int):
|
| 977 |
today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 978 |
month_str = datetime.now(timezone.utc).strftime("%Y-%m")
|
|
|
|
| 78 |
upsert=True
|
| 79 |
)
|
| 80 |
|
| 81 |
+
# -------------------------------
|
| 82 |
+
# User Subscription Management
|
| 83 |
+
# -------------------------------
|
| 84 |
+
async def get_user(self, user_id: int) -> Optional[dict]:
|
| 85 |
+
return await self.dbs["tracking"]["users"].find_one({"_id": user_id})
|
| 86 |
+
|
| 87 |
+
async def update_user_interaction(self, user_id: int, first_name: str, username: str):
|
| 88 |
+
await self.dbs["tracking"]["users"].update_one(
|
| 89 |
+
{"_id": user_id},
|
| 90 |
+
{"$set": {"first_name": first_name, "username": username, "last_interaction": datetime.utcnow()}},
|
| 91 |
+
upsert=True
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
async def set_pending_payment(self, user_id: int, plan_duration: int, msg_id: int, price=0, admin_messages: list = None):
|
| 95 |
+
update_data = {
|
| 96 |
+
"pending_payment": {
|
| 97 |
+
"duration": plan_duration,
|
| 98 |
+
"price": price,
|
| 99 |
+
"msg_id": msg_id,
|
| 100 |
+
"date": datetime.utcnow(),
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
if admin_messages is not None:
|
| 104 |
+
update_data["pending_payment"]["admin_messages"] = admin_messages
|
| 105 |
+
await self.dbs["tracking"]["users"].update_one(
|
| 106 |
+
{"_id": user_id},
|
| 107 |
+
{"$set": update_data},
|
| 108 |
+
upsert=True
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
async def approve_payment(self, user_id: int) -> Optional[dict]:
|
| 112 |
+
user = await self.get_user(user_id)
|
| 113 |
+
if not user or "pending_payment" not in user:
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
duration = user["pending_payment"]["duration"]
|
| 117 |
+
|
| 118 |
+
# Calculate new expiry
|
| 119 |
+
current_expiry = user.get("subscription_expiry")
|
| 120 |
+
now = datetime.utcnow()
|
| 121 |
+
if current_expiry and current_expiry > now:
|
| 122 |
+
from datetime import timedelta
|
| 123 |
+
new_expiry = current_expiry + timedelta(days=duration)
|
| 124 |
+
else:
|
| 125 |
+
from datetime import timedelta
|
| 126 |
+
new_expiry = now + timedelta(days=duration)
|
| 127 |
+
|
| 128 |
+
await self.dbs["tracking"]["users"].update_one(
|
| 129 |
+
{"_id": user_id},
|
| 130 |
+
{
|
| 131 |
+
"$set": {"subscription_expiry": new_expiry, "subscription_status": "active"},
|
| 132 |
+
"$unset": {"pending_payment": ""}
|
| 133 |
+
}
|
| 134 |
+
)
|
| 135 |
+
return await self.get_user(user_id)
|
| 136 |
+
|
| 137 |
+
async def reject_payment(self, user_id: int) -> bool:
|
| 138 |
+
result = await self.dbs["tracking"]["users"].update_one(
|
| 139 |
+
{"_id": user_id},
|
| 140 |
+
{"$unset": {"pending_payment": ""}}
|
| 141 |
+
)
|
| 142 |
+
return result.modified_count > 0
|
| 143 |
+
|
| 144 |
+
async def get_expired_users(self) -> List[dict]:
|
| 145 |
+
cursor = self.dbs["tracking"]["users"].find({
|
| 146 |
+
"subscription_expiry": {"$lt": datetime.utcnow()},
|
| 147 |
+
"subscription_status": "active"
|
| 148 |
+
})
|
| 149 |
+
return await cursor.to_list(None)
|
| 150 |
+
|
| 151 |
+
async def mark_user_expired(self, user_id: int):
|
| 152 |
+
await self.dbs["tracking"]["users"].update_one(
|
| 153 |
+
{"_id": user_id},
|
| 154 |
+
{"$set": {"subscription_status": "expired"}}
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
async def get_expiring_users(self, hours: int = 24) -> List[dict]:
|
| 158 |
+
from datetime import timedelta
|
| 159 |
+
now = datetime.utcnow()
|
| 160 |
+
target_time = now + timedelta(hours=hours)
|
| 161 |
+
cursor = self.dbs["tracking"]["users"].find({
|
| 162 |
+
"subscription_expiry": {"$gt": now, "$lte": target_time},
|
| 163 |
+
"reminder_sent": {"$ne": True},
|
| 164 |
+
"subscription_status": "active"
|
| 165 |
+
})
|
| 166 |
+
return await cursor.to_list(None)
|
| 167 |
+
|
| 168 |
+
async def mark_reminder_sent(self, user_id: int):
|
| 169 |
+
await self.dbs["tracking"]["users"].update_one(
|
| 170 |
+
{"_id": user_id},
|
| 171 |
+
{"$set": {"reminder_sent": True}}
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
# -------------------------------
|
| 175 |
+
# Admin Subscription Management
|
| 176 |
+
# -------------------------------
|
| 177 |
+
async def get_subscription_plans(self) -> List[dict]:
|
| 178 |
+
cursor = self.dbs["tracking"]["sub_plans"].find().sort("days", ASCENDING)
|
| 179 |
+
plans = await cursor.to_list(None)
|
| 180 |
+
return [convert_objectid_to_str(plan) for plan in plans]
|
| 181 |
+
|
| 182 |
+
async def add_subscription_plan(self, days: int, price: float) -> Optional[str]:
|
| 183 |
+
result = await self.dbs["tracking"]["sub_plans"].insert_one({
|
| 184 |
+
"days": days,
|
| 185 |
+
"price": price,
|
| 186 |
+
"created_at": datetime.utcnow()
|
| 187 |
+
})
|
| 188 |
+
return str(result.inserted_id)
|
| 189 |
+
|
| 190 |
+
async def update_subscription_plan(self, plan_id: str, days: int, price: float) -> bool:
|
| 191 |
+
try:
|
| 192 |
+
result = await self.dbs["tracking"]["sub_plans"].update_one(
|
| 193 |
+
{"_id": ObjectId(plan_id)},
|
| 194 |
+
{"$set": {"days": days, "price": price, "updated_at": datetime.utcnow()}}
|
| 195 |
+
)
|
| 196 |
+
return result.modified_count > 0
|
| 197 |
+
except Exception:
|
| 198 |
+
return False
|
| 199 |
+
|
| 200 |
+
async def delete_subscription_plan(self, plan_id: str) -> bool:
|
| 201 |
+
try:
|
| 202 |
+
result = await self.dbs["tracking"]["sub_plans"].delete_one({"_id": ObjectId(plan_id)})
|
| 203 |
+
return result.deleted_count > 0
|
| 204 |
+
except Exception:
|
| 205 |
+
return False
|
| 206 |
+
|
| 207 |
+
async def get_all_subscribers(self) -> List[dict]:
|
| 208 |
+
cursor = self.dbs["tracking"]["users"].find({
|
| 209 |
+
"subscription_status": {"$in": ["active", "expired"]}
|
| 210 |
+
}).sort("subscription_expiry", DESCENDING)
|
| 211 |
+
users = await cursor.to_list(None)
|
| 212 |
+
return [convert_objectid_to_str(u) for u in users]
|
| 213 |
+
|
| 214 |
+
async def manage_subscriber(self, user_id: int, action: str, days: int = 0) -> bool:
|
| 215 |
+
user = await self.get_user(user_id)
|
| 216 |
+
if not user:
|
| 217 |
+
return False
|
| 218 |
+
|
| 219 |
+
now = datetime.utcnow()
|
| 220 |
+
if action == "extend" or action == "reduce":
|
| 221 |
+
from datetime import timedelta
|
| 222 |
+
current_expiry = user.get("subscription_expiry")
|
| 223 |
+
|
| 224 |
+
if action == "extend":
|
| 225 |
+
if current_expiry and current_expiry > now:
|
| 226 |
+
new_expiry = current_expiry + timedelta(days=days)
|
| 227 |
+
else:
|
| 228 |
+
new_expiry = now + timedelta(days=days)
|
| 229 |
+
else: # reduce
|
| 230 |
+
if current_expiry:
|
| 231 |
+
new_expiry = current_expiry - timedelta(days=days)
|
| 232 |
+
if new_expiry < now:
|
| 233 |
+
new_expiry = now # Just expire them
|
| 234 |
+
else:
|
| 235 |
+
new_expiry = now # Already expired or none
|
| 236 |
+
|
| 237 |
+
status = "active" if new_expiry > now else "expired"
|
| 238 |
+
|
| 239 |
+
result = await self.dbs["tracking"]["users"].update_one(
|
| 240 |
+
{"_id": user_id},
|
| 241 |
+
{"$set": {"subscription_expiry": new_expiry, "subscription_status": status}}
|
| 242 |
+
)
|
| 243 |
+
return result.modified_count > 0
|
| 244 |
+
|
| 245 |
+
elif action == "delete":
|
| 246 |
+
result = await self.dbs["tracking"]["users"].update_one(
|
| 247 |
+
{"_id": user_id},
|
| 248 |
+
{"$unset": {"subscription_expiry": "", "subscription_status": ""}}
|
| 249 |
+
)
|
| 250 |
+
return result.modified_count > 0
|
| 251 |
+
|
| 252 |
+
return False
|
| 253 |
+
|
| 254 |
+
async def assign_subscription(self, user_id: int, days: int) -> dict:
|
| 255 |
+
"""Upsert a subscription for any user_id, creating a record if it doesn't exist."""
|
| 256 |
+
from datetime import timedelta
|
| 257 |
+
now = datetime.utcnow()
|
| 258 |
+
|
| 259 |
+
user = await self.get_user(user_id)
|
| 260 |
+
if user:
|
| 261 |
+
current_expiry = user.get("subscription_expiry")
|
| 262 |
+
if current_expiry and current_expiry > now:
|
| 263 |
+
new_expiry = current_expiry + timedelta(days=days)
|
| 264 |
+
else:
|
| 265 |
+
new_expiry = now + timedelta(days=days)
|
| 266 |
+
else:
|
| 267 |
+
new_expiry = now + timedelta(days=days)
|
| 268 |
+
|
| 269 |
+
await self.dbs["tracking"]["users"].update_one(
|
| 270 |
+
{"_id": user_id},
|
| 271 |
+
{
|
| 272 |
+
"$set": {
|
| 273 |
+
"subscription_expiry": new_expiry,
|
| 274 |
+
"subscription_status": "active",
|
| 275 |
+
},
|
| 276 |
+
"$setOnInsert": {
|
| 277 |
+
"_id": user_id,
|
| 278 |
+
"first_name": f"User {user_id}",
|
| 279 |
+
"username": None,
|
| 280 |
+
"created_at": now,
|
| 281 |
+
}
|
| 282 |
+
},
|
| 283 |
+
upsert=True
|
| 284 |
+
)
|
| 285 |
+
return {
|
| 286 |
+
"user_id": user_id,
|
| 287 |
+
"subscription_expiry": new_expiry.isoformat(),
|
| 288 |
+
"subscription_status": "active",
|
| 289 |
+
"days_assigned": days,
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
|
| 293 |
# -------------------------------
|
| 294 |
# Helper Methods for Repeated Logic
|
|
|
|
| 1149 |
# API Token Methods
|
| 1150 |
# -------------------------------
|
| 1151 |
|
| 1152 |
+
async def add_api_token(self, name: str, daily_limit_gb: float = None, monthly_limit_gb: float = None, user_id: int = None) -> dict:
|
| 1153 |
+
# If a user_id is provided, return existing token if already created
|
| 1154 |
+
if user_id:
|
| 1155 |
+
existing = await self.dbs["tracking"]["api_tokens"].find_one({"user_id": user_id})
|
| 1156 |
+
if existing:
|
| 1157 |
+
return convert_objectid_to_str(existing)
|
| 1158 |
+
|
| 1159 |
alphabet = string.ascii_letters + string.digits
|
| 1160 |
token = ''.join(secrets.choice(alphabet) for _ in range(32))
|
| 1161 |
|
| 1162 |
token_doc = {
|
| 1163 |
"name": name,
|
| 1164 |
"token": token,
|
| 1165 |
+
"user_id": user_id,
|
| 1166 |
"created_at": datetime.utcnow(),
|
| 1167 |
"limits": {
|
| 1168 |
"daily_limit_gb": daily_limit_gb if daily_limit_gb else 0,
|
|
|
|
| 1191 |
result = await self.dbs["tracking"]["api_tokens"].delete_one({"token": token})
|
| 1192 |
return result.deleted_count > 0
|
| 1193 |
|
| 1194 |
+
async def link_token_user(self, token: str, user_id: int) -> bool:
|
| 1195 |
+
"""Link an existing token to a Telegram user_id."""
|
| 1196 |
+
result = await self.dbs["tracking"]["api_tokens"].update_one(
|
| 1197 |
+
{"token": token},
|
| 1198 |
+
{"$set": {"user_id": user_id}}
|
| 1199 |
+
)
|
| 1200 |
+
return result.modified_count > 0
|
| 1201 |
+
|
| 1202 |
async def update_token_usage(self, token: str, bytes_delta: int):
|
| 1203 |
today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 1204 |
month_str = datetime.now(timezone.utc).strftime("%Y-%m")
|
Backend/helper/subscription_checker.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from pyrogram import Client
|
| 3 |
+
from Backend.config import Telegram
|
| 4 |
+
from Backend import db
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from Backend.logger import LOGGER
|
| 7 |
+
|
| 8 |
+
async def subscription_checker_loop(bot: Client):
|
| 9 |
+
while True:
|
| 10 |
+
try:
|
| 11 |
+
if not Telegram.SUBSCRIPTION:
|
| 12 |
+
await asyncio.sleep(3600)
|
| 13 |
+
continue
|
| 14 |
+
|
| 15 |
+
LOGGER.info("Running subscription checker...")
|
| 16 |
+
|
| 17 |
+
# 1. Fetch expired users & kick them
|
| 18 |
+
expired_users = await db.get_expired_users()
|
| 19 |
+
for user in expired_users:
|
| 20 |
+
user_id = user["_id"]
|
| 21 |
+
try:
|
| 22 |
+
# Ban then unban to kick the user without permanently banning them
|
| 23 |
+
await bot.ban_chat_member(Telegram.SUBSCRIPTION_GROUP_ID, user_id)
|
| 24 |
+
await bot.unban_chat_member(Telegram.SUBSCRIPTION_GROUP_ID, user_id)
|
| 25 |
+
|
| 26 |
+
await db.mark_user_expired(user_id)
|
| 27 |
+
|
| 28 |
+
# Notify user
|
| 29 |
+
await bot.send_message(
|
| 30 |
+
user_id,
|
| 31 |
+
"β <b>Subscription Expired</b>\n\n"
|
| 32 |
+
"Your subscription has expired, and you have been removed from the private group.\n"
|
| 33 |
+
"Send /start to renew your subscription and regain access."
|
| 34 |
+
)
|
| 35 |
+
LOGGER.info(f"Kicked expired user {user_id}")
|
| 36 |
+
except Exception as e:
|
| 37 |
+
LOGGER.error(f"Failed to kick/notify expired user {user_id}: {e}")
|
| 38 |
+
|
| 39 |
+
# 2. Remind users expiring in 24 hours
|
| 40 |
+
expiring_users = await db.get_expiring_users(hours=24)
|
| 41 |
+
for user in expiring_users:
|
| 42 |
+
user_id = user["_id"]
|
| 43 |
+
expiry = user["subscription_expiry"]
|
| 44 |
+
try:
|
| 45 |
+
await bot.send_message(
|
| 46 |
+
user_id,
|
| 47 |
+
f"β οΈ <b>Subscription Expiring Soon</b>\n\n"
|
| 48 |
+
f"Your subscription will expire on <b>{expiry.strftime('%Y-%m-%d %H:%M UTC')}</b>.\n"
|
| 49 |
+
"Send /start to renew your plan before you lose access to the group!"
|
| 50 |
+
)
|
| 51 |
+
await db.mark_reminder_sent(user_id)
|
| 52 |
+
LOGGER.info(f"Sent expiry reminder to user {user_id}")
|
| 53 |
+
except Exception as e:
|
| 54 |
+
LOGGER.error(f"Failed to send reminder to user {user_id}: {e}")
|
| 55 |
+
|
| 56 |
+
# Check every hour
|
| 57 |
+
await asyncio.sleep(3600)
|
| 58 |
+
|
| 59 |
+
except Exception as e:
|
| 60 |
+
LOGGER.error(f"Error in subscription checker loop: {e}")
|
| 61 |
+
await asyncio.sleep(300) # Wait 5 minutes before retrying on error
|
Backend/pyrofork/plugins/group_security.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pyrogram import Client, filters
|
| 2 |
+
from pyrogram.types import ChatMemberUpdated
|
| 3 |
+
from Backend.config import Telegram
|
| 4 |
+
from Backend import db
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from Backend.logger import LOGGER
|
| 7 |
+
from pyrogram.enums import ChatMemberStatus
|
| 8 |
+
|
| 9 |
+
@Client.on_chat_member_updated()
|
| 10 |
+
async def on_user_join(client: Client, chat_member_updated: ChatMemberUpdated):
|
| 11 |
+
if not Telegram.SUBSCRIPTION:
|
| 12 |
+
return
|
| 13 |
+
|
| 14 |
+
# Check if this update is for the subscription group
|
| 15 |
+
if chat_member_updated.chat.id != Telegram.SUBSCRIPTION_GROUP_ID:
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
# Check if it's a join event (user was not a member, now is a member)
|
| 19 |
+
old_status = chat_member_updated.old_chat_member.status if chat_member_updated.old_chat_member else None
|
| 20 |
+
new_status = chat_member_updated.new_chat_member.status if chat_member_updated.new_chat_member else None
|
| 21 |
+
|
| 22 |
+
if new_status == ChatMemberStatus.MEMBER:
|
| 23 |
+
user = chat_member_updated.new_chat_member.user
|
| 24 |
+
|
| 25 |
+
# Verify subscription
|
| 26 |
+
db_user = await db.get_user(user.id)
|
| 27 |
+
is_active = False
|
| 28 |
+
if db_user and db_user.get("subscription_status") == "active":
|
| 29 |
+
if db_user.get("subscription_expiry") and db_user.get("subscription_expiry") > datetime.utcnow():
|
| 30 |
+
is_active = True
|
| 31 |
+
|
| 32 |
+
if not is_active:
|
| 33 |
+
try:
|
| 34 |
+
# Kick the user
|
| 35 |
+
await client.ban_chat_member(chat_member_updated.chat.id, user.id)
|
| 36 |
+
await client.unban_chat_member(chat_member_updated.chat.id, user.id)
|
| 37 |
+
LOGGER.info(f"Kicked unauthorized user {user.id} from group {chat_member_updated.chat.id}")
|
| 38 |
+
|
| 39 |
+
# Notify them
|
| 40 |
+
await client.send_message(
|
| 41 |
+
user.id,
|
| 42 |
+
"β <b>Access Denied</b>\n\n"
|
| 43 |
+
"You were removed from the private group because you do not have an active subscription.\n"
|
| 44 |
+
"Please press /start in this bot to purchase or renew your subscription."
|
| 45 |
+
)
|
| 46 |
+
except Exception as e:
|
| 47 |
+
LOGGER.error(f"Failed to kick/notify unauthorized user {user.id}: {e}")
|
Backend/pyrofork/plugins/help.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pyrogram import Client, filters
|
| 2 |
+
from pyrogram.types import Message
|
| 3 |
+
from Backend.config import Telegram
|
| 4 |
+
from Backend.helper.custom_filter import CustomFilters
|
| 5 |
+
|
| 6 |
+
@Client.on_message(filters.command("help"))
|
| 7 |
+
async def help_command(client: Client, message: Message):
|
| 8 |
+
if Telegram.SUBSCRIPTION:
|
| 9 |
+
text = (
|
| 10 |
+
"<b>Bot Commands:</b>\n\n"
|
| 11 |
+
"/start - Main menu / Purchase membership\n"
|
| 12 |
+
"/status - Check your subscription expiry date\n"
|
| 13 |
+
"/help - Show this message"
|
| 14 |
+
)
|
| 15 |
+
else:
|
| 16 |
+
text = (
|
| 17 |
+
"<b>Bot Commands:</b>\n\n"
|
| 18 |
+
"/start - Get the Stremio Addon URL\n"
|
| 19 |
+
"/help - Show this message"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
await message.reply_text(text, quote=True)
|
Backend/pyrofork/plugins/start.py
CHANGED
|
@@ -1,18 +1,78 @@
|
|
| 1 |
from pyrogram import filters, Client, enums
|
|
|
|
| 2 |
from Backend.helper.custom_filter import CustomFilters
|
| 3 |
-
from pyrogram.types import Message
|
| 4 |
from Backend.config import Telegram
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
async def send_start_message(client: Client, message: Message):
|
| 8 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
base_url = Telegram.BASE_URL
|
| 10 |
addon_url = f"{base_url}/stremio/manifest.json"
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
await message.reply_text(
|
| 13 |
-
'<b>Welcome to the
|
| 14 |
-
'
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
quote=True,
|
| 17 |
parse_mode=enums.ParseMode.HTML
|
| 18 |
)
|
|
|
|
| 1 |
from pyrogram import filters, Client, enums
|
| 2 |
+
from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
| 3 |
from Backend.helper.custom_filter import CustomFilters
|
|
|
|
| 4 |
from Backend.config import Telegram
|
| 5 |
+
from Backend import db
|
| 6 |
+
from datetime import datetime
|
| 7 |
|
| 8 |
+
print("DEBUG: start.py PLUGIN LOADED SUCCESSFULLY!")
|
| 9 |
+
|
| 10 |
+
@Client.on_message(filters.command('start'), group=10)
|
| 11 |
async def send_start_message(client: Client, message: Message):
|
| 12 |
try:
|
| 13 |
+
user_id = (message.from_user.id if message.from_user else None) or (message.sender_chat.id if message.sender_chat else None) or message.chat.id
|
| 14 |
+
print(f"DEBUG: Received /start command from {user_id}")
|
| 15 |
+
# await message.reply_text("DEBUG: Bot received the start command.")
|
| 16 |
+
|
| 17 |
base_url = Telegram.BASE_URL
|
| 18 |
addon_url = f"{base_url}/stremio/manifest.json"
|
| 19 |
|
| 20 |
+
# If subscriptions are NOT enabled, only the OWNER should use the bot
|
| 21 |
+
if not Telegram.SUBSCRIPTION and user_id != Telegram.OWNER_ID:
|
| 22 |
+
return
|
| 23 |
+
|
| 24 |
+
# Subscription logic
|
| 25 |
+
if Telegram.SUBSCRIPTION:
|
| 26 |
+
user = await db.get_user(user_id)
|
| 27 |
+
now = datetime.utcnow()
|
| 28 |
+
|
| 29 |
+
# Check if user has an active subscription
|
| 30 |
+
is_active = False
|
| 31 |
+
if user and user.get("subscription_status") == "active":
|
| 32 |
+
if user.get("subscription_expiry") and user.get("subscription_expiry") > now:
|
| 33 |
+
is_active = True
|
| 34 |
+
else:
|
| 35 |
+
await db.mark_user_expired(user_id)
|
| 36 |
+
|
| 37 |
+
if not is_active:
|
| 38 |
+
plans = await db.get_subscription_plans()
|
| 39 |
+
if not plans:
|
| 40 |
+
return await message.reply_text(
|
| 41 |
+
'<b>Welcome to the Telegram Stremio Private Group!</b>\n\n'
|
| 42 |
+
'Currently, no subscription plans are set up. Please contact the administrator.',
|
| 43 |
+
quote=True,
|
| 44 |
+
parse_mode=enums.ParseMode.HTML
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
keyboard_buttons = []
|
| 48 |
+
for plan in plans:
|
| 49 |
+
keyboard_buttons.append([InlineKeyboardButton(f"{plan['days']} Days - βΉ{plan['price']}", callback_data=f"plan_{plan['_id']}")])
|
| 50 |
+
|
| 51 |
+
keyboard = InlineKeyboardMarkup(keyboard_buttons)
|
| 52 |
+
|
| 53 |
+
return await message.reply_text(
|
| 54 |
+
'<b>Welcome to the Telegram Stremio Private Group!</b>\n\n'
|
| 55 |
+
'Access to this bot and the Stremio Addon requires an active subscription.\n'
|
| 56 |
+
'Please select a subscription plan below to continue:',
|
| 57 |
+
reply_markup=keyboard,
|
| 58 |
+
quote=True,
|
| 59 |
+
parse_mode=enums.ParseMode.HTML
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# User is active, fetch their token
|
| 63 |
+
all_tokens = await db.get_all_api_tokens()
|
| 64 |
+
token_doc = next((t for t in all_tokens if t.get("user_id") == user_id), None)
|
| 65 |
+
|
| 66 |
+
if token_doc and "token" in token_doc:
|
| 67 |
+
token_str = token_doc["token"]
|
| 68 |
+
addon_url = f"{base_url}/stremio/{token_str}/manifest.json"
|
| 69 |
+
|
| 70 |
await message.reply_text(
|
| 71 |
+
'π <b>Welcome to the Telegram Stremio Media Server!</b>\n\n'
|
| 72 |
+
'Your subscription is active. Here is your personal addon link:\n\n'
|
| 73 |
+
'π¬ <b>Stremio Addon β Install Link:</b>\n'
|
| 74 |
+
f'<code>{addon_url}</code>\n\n'
|
| 75 |
+
'Tap the link above β <b>Install</b> in Stremio to start watching!',
|
| 76 |
quote=True,
|
| 77 |
parse_mode=enums.ParseMode.HTML
|
| 78 |
)
|
Backend/pyrofork/plugins/subscription.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pyrogram import Client, filters
|
| 2 |
+
from pyrogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
| 3 |
+
from Backend.config import Telegram
|
| 4 |
+
from Backend import db
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
import asyncio
|
| 7 |
+
|
| 8 |
+
from bson.objectid import ObjectId
|
| 9 |
+
|
| 10 |
+
@Client.on_callback_query(filters.regex(r"^plan_([a-fA-F0-9]{24})$"))
|
| 11 |
+
async def plan_selection(client: Client, callback_query: CallbackQuery):
|
| 12 |
+
if not Telegram.SUBSCRIPTION:
|
| 13 |
+
return await callback_query.answer("Subscriptions are not enabled.", show_alert=True)
|
| 14 |
+
|
| 15 |
+
plan_id = callback_query.matches[0].group(1)
|
| 16 |
+
|
| 17 |
+
plans = await db.get_subscription_plans()
|
| 18 |
+
plan = next((p for p in plans if p["_id"] == plan_id), None)
|
| 19 |
+
|
| 20 |
+
if not plan:
|
| 21 |
+
return await callback_query.answer("Invalid plan.", show_alert=True)
|
| 22 |
+
|
| 23 |
+
duration = plan["days"]
|
| 24 |
+
await callback_query.answer()
|
| 25 |
+
|
| 26 |
+
user_id = callback_query.from_user.id if callback_query.from_user else callback_query.message.chat.id
|
| 27 |
+
first_name = callback_query.from_user.first_name if callback_query.from_user else callback_query.message.chat.title
|
| 28 |
+
username = callback_query.from_user.username if callback_query.from_user else callback_query.message.chat.username
|
| 29 |
+
|
| 30 |
+
# Store initial user interaction if needed
|
| 31 |
+
await db.update_user_interaction(user_id, first_name, username)
|
| 32 |
+
|
| 33 |
+
# Calculate expiry
|
| 34 |
+
user = await db.get_user(user_id)
|
| 35 |
+
now = datetime.utcnow()
|
| 36 |
+
current_expiry = user.get("subscription_expiry") if user else None
|
| 37 |
+
|
| 38 |
+
if current_expiry and current_expiry > now:
|
| 39 |
+
new_expiry = current_expiry + timedelta(days=int(duration))
|
| 40 |
+
else:
|
| 41 |
+
new_expiry = now + timedelta(days=int(duration))
|
| 42 |
+
|
| 43 |
+
expiry_str = new_expiry.strftime("%Y-%m-%d %H:%M UTC")
|
| 44 |
+
|
| 45 |
+
text = (
|
| 46 |
+
f"<b>β
Plan Selected: {plan['days']} Days</b>\n\n"
|
| 47 |
+
f"<b>π° Price:</b> βΉ{plan['price']}\n"
|
| 48 |
+
f"<b>π
Expiry (if approved now):</b> {expiry_str}\n\n"
|
| 49 |
+
f"<b>π Payment Instructions:</b>\n"
|
| 50 |
+
f"1. Pay βΉ{plan['price']} to the admin.\n"
|
| 51 |
+
f"2. <b>Send your payment screenshot directly here (in this chat)</b>.\n"
|
| 52 |
+
f" The admin will review and activate your subscription."
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Set pending payment state (price stored for admin display)
|
| 56 |
+
await db.set_pending_payment(user_id, int(duration), 0, price=plan.get("price", 0))
|
| 57 |
+
|
| 58 |
+
# Always try to DM the user directly so the screenshot handler (filters.private) picks it up
|
| 59 |
+
dm_sent = False
|
| 60 |
+
from pyrogram.types import ForceReply
|
| 61 |
+
|
| 62 |
+
# Always try to DM the user directly so the screenshot handler (filters.private) picks it up
|
| 63 |
+
dm_sent = False
|
| 64 |
+
try:
|
| 65 |
+
await client.send_message(
|
| 66 |
+
chat_id=user_id,
|
| 67 |
+
text=text,
|
| 68 |
+
reply_markup=ForceReply(selective=True),
|
| 69 |
+
)
|
| 70 |
+
dm_sent = True
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"Could not DM user {user_id}: {e}")
|
| 73 |
+
|
| 74 |
+
if dm_sent:
|
| 75 |
+
await callback_query.answer("β
Check your DM for payment instructions!", show_alert=True)
|
| 76 |
+
else:
|
| 77 |
+
# Fallback: reply in current chat if DM fails (user hasn't started bot)
|
| 78 |
+
await callback_query.message.reply_text(
|
| 79 |
+
text + "\n\nβ οΈ <i>Please start a DM with the bot first by clicking its username, then send your screenshot there.</i>",
|
| 80 |
+
reply_markup=ForceReply(selective=True),
|
| 81 |
+
quote=True,
|
| 82 |
+
)
|
| 83 |
+
await callback_query.answer()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@Client.on_message(filters.photo & filters.private)
|
| 87 |
+
async def handle_payment_screenshot(client: Client, message: Message):
|
| 88 |
+
if not Telegram.SUBSCRIPTION:
|
| 89 |
+
return
|
| 90 |
+
|
| 91 |
+
# Safely resolve sender ID
|
| 92 |
+
sender_id = (message.from_user.id if message.from_user else None) \
|
| 93 |
+
or (message.sender_chat.id if message.sender_chat else None) \
|
| 94 |
+
or message.chat.id
|
| 95 |
+
|
| 96 |
+
try:
|
| 97 |
+
print(f"DEBUG: handle_payment_screenshot triggered by {sender_id}")
|
| 98 |
+
# Check if user has a pending payment request
|
| 99 |
+
user = await db.get_user(sender_id)
|
| 100 |
+
print(f"DEBUG: user from DB = {user}")
|
| 101 |
+
if not user or "pending_payment" not in user:
|
| 102 |
+
# No active payment flow - tell the user what to do
|
| 103 |
+
print(f"DEBUG: No pending_payment found for {sender_id}")
|
| 104 |
+
await message.reply_text(
|
| 105 |
+
"βΉοΈ We received your photo, but you don't have an active payment request.\n\n"
|
| 106 |
+
"Please use /start to select a subscription plan first, then send your payment screenshot.",
|
| 107 |
+
quote=True
|
| 108 |
+
)
|
| 109 |
+
return
|
| 110 |
+
|
| 111 |
+
pending = user["pending_payment"]
|
| 112 |
+
duration = pending.get("duration", "?")
|
| 113 |
+
price = pending.get("price", "?")
|
| 114 |
+
|
| 115 |
+
# --- Admin notification ---
|
| 116 |
+
keyboard = InlineKeyboardMarkup([
|
| 117 |
+
[
|
| 118 |
+
InlineKeyboardButton("β
Approve", callback_data=f"approve_{sender_id}"),
|
| 119 |
+
InlineKeyboardButton("β Reject", callback_data=f"reject_{sender_id}")
|
| 120 |
+
]
|
| 121 |
+
])
|
| 122 |
+
|
| 123 |
+
user_mention = message.from_user.mention if message.from_user else f"User {sender_id}"
|
| 124 |
+
username_str = f"@{message.from_user.username}" if (message.from_user and message.from_user.username) else "N/A"
|
| 125 |
+
|
| 126 |
+
admin_text = (
|
| 127 |
+
f"<b>π° New Payment Screenshot Received</b>\n\n"
|
| 128 |
+
f"<b>π€ User:</b> {user_mention}\n"
|
| 129 |
+
f"<b>π User ID:</b> <code>{sender_id}</code>\n"
|
| 130 |
+
f"<b>π Username:</b> {username_str}\n\n"
|
| 131 |
+
f"<b>π¦ Plan Details:</b>\n"
|
| 132 |
+
f" β’ Duration: <b>{duration} days</b>\n"
|
| 133 |
+
f" β’ Price: <b>βΉ{price}</b>\n\n"
|
| 134 |
+
f"Please review the screenshot above and approve or reject."
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
approver_ids = Telegram.APPROVER_IDS if Telegram.APPROVER_IDS else [Telegram.OWNER_ID]
|
| 138 |
+
admin_messages = []
|
| 139 |
+
for approver_id in approver_ids:
|
| 140 |
+
try:
|
| 141 |
+
sent = await message.copy(approver_id, caption=admin_text, reply_markup=keyboard)
|
| 142 |
+
admin_messages.append({"chat_id": approver_id, "message_id": sent.id})
|
| 143 |
+
except Exception as e:
|
| 144 |
+
print(f"Failed to forward screenshot to approver {approver_id}: {e}")
|
| 145 |
+
|
| 146 |
+
# Update pending state with screenshot message ID + all admin message IDs
|
| 147 |
+
await db.set_pending_payment(sender_id, duration, message.id, price=price,
|
| 148 |
+
admin_messages=admin_messages)
|
| 149 |
+
|
| 150 |
+
if admin_messages:
|
| 151 |
+
await message.reply_text(
|
| 152 |
+
"β
<b>Screenshot Received!</b>\n\n"
|
| 153 |
+
"Your payment screenshot has been forwarded to the admin for review.\n"
|
| 154 |
+
"You will be notified once it's approved. Thank you! π",
|
| 155 |
+
quote=True
|
| 156 |
+
)
|
| 157 |
+
else:
|
| 158 |
+
await message.reply_text(
|
| 159 |
+
"β οΈ Your screenshot was received but we could not reach the admin. "
|
| 160 |
+
"Please contact the admin directly.",
|
| 161 |
+
quote=True
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
except Exception as e:
|
| 165 |
+
print(f"Error in handle_payment_screenshot: {e}")
|
| 166 |
+
await message.reply_text(
|
| 167 |
+
f"β οΈ Something went wrong while processing your screenshot. Please try again or contact the admin.\n\nError: {e}",
|
| 168 |
+
quote=True
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@Client.on_callback_query(filters.regex(r"^(approve|reject)_(\d+)$"))
|
| 173 |
+
async def admin_review(client: Client, callback_query: CallbackQuery):
|
| 174 |
+
approver_ids = Telegram.APPROVER_IDS if Telegram.APPROVER_IDS else [Telegram.OWNER_ID]
|
| 175 |
+
if callback_query.from_user.id not in approver_ids:
|
| 176 |
+
return await callback_query.answer("You are not authorized to perform this action.", show_alert=True)
|
| 177 |
+
|
| 178 |
+
action = callback_query.matches[0].group(1)
|
| 179 |
+
target_user_id = int(callback_query.matches[0].group(2))
|
| 180 |
+
acting_admin = callback_query.from_user
|
| 181 |
+
admin_name = acting_admin.first_name or acting_admin.username or f"Admin {acting_admin.id}"
|
| 182 |
+
|
| 183 |
+
# Fetch admin_messages BEFORE any DB write (approve/reject unsets pending_payment)
|
| 184 |
+
user_pre = await db.get_user(target_user_id)
|
| 185 |
+
if not user_pre or "pending_payment" not in user_pre:
|
| 186 |
+
return await callback_query.answer("This request has already been processed.", show_alert=True)
|
| 187 |
+
|
| 188 |
+
admin_messages = user_pre["pending_payment"].get("admin_messages", [])
|
| 189 |
+
|
| 190 |
+
if action == "approve":
|
| 191 |
+
user_data = await db.approve_payment(target_user_id)
|
| 192 |
+
if user_data:
|
| 193 |
+
# Generate or retrieve existing API token for this user
|
| 194 |
+
try:
|
| 195 |
+
user_obj = await db.get_user(target_user_id)
|
| 196 |
+
user_name = (user_obj.get("first_name") or user_obj.get("username") or str(target_user_id)) if user_obj else str(target_user_id)
|
| 197 |
+
token_doc = await db.add_api_token(name=user_name, user_id=target_user_id)
|
| 198 |
+
token_str = token_doc.get("token")
|
| 199 |
+
addon_url = f"{Telegram.BASE_URL}/stremio/{token_str}/manifest.json"
|
| 200 |
+
except Exception as te:
|
| 201 |
+
token_str = None
|
| 202 |
+
addon_url = None
|
| 203 |
+
|
| 204 |
+
# Generate invite link for the group
|
| 205 |
+
try:
|
| 206 |
+
invite_link = await client.create_chat_invite_link(
|
| 207 |
+
chat_id=Telegram.SUBSCRIPTION_GROUP_ID,
|
| 208 |
+
member_limit=1,
|
| 209 |
+
expire_date=datetime.utcnow() + timedelta(days=1)
|
| 210 |
+
)
|
| 211 |
+
invite_text = f"\n\nπ <b>Group Invite:</b> {invite_link.invite_link}"
|
| 212 |
+
except Exception:
|
| 213 |
+
invite_text = ""
|
| 214 |
+
|
| 215 |
+
expiry_str = user_data["subscription_expiry"].strftime("%Y-%m-%d")
|
| 216 |
+
|
| 217 |
+
# Build confirmation message for user
|
| 218 |
+
success_text = (
|
| 219 |
+
f"π <b>Payment Approved!</b>\n\n"
|
| 220 |
+
f"Your subscription is now active until <b>{expiry_str}</b>."
|
| 221 |
+
f"{invite_text}"
|
| 222 |
+
)
|
| 223 |
+
if addon_url:
|
| 224 |
+
success_text += (
|
| 225 |
+
f"\n\nπ¬ <b>Stremio Addon β Install Link:</b>\n"
|
| 226 |
+
f"<code>{addon_url}</code>\n\n"
|
| 227 |
+
f"Tap the link above β <b>Install</b> in Stremio to start watching!"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
await client.send_message(target_user_id, success_text)
|
| 231 |
+
|
| 232 |
+
# Update acting admin's message
|
| 233 |
+
status_caption = f"β
<b>Approved</b> by {admin_name}."
|
| 234 |
+
await callback_query.message.edit_caption(status_caption)
|
| 235 |
+
|
| 236 |
+
# Update all OTHER admins' copies
|
| 237 |
+
acting_msg_id = callback_query.message.id
|
| 238 |
+
for am in admin_messages:
|
| 239 |
+
if am["message_id"] == acting_msg_id:
|
| 240 |
+
continue
|
| 241 |
+
try:
|
| 242 |
+
await client.edit_message_caption(
|
| 243 |
+
chat_id=am["chat_id"],
|
| 244 |
+
message_id=am["message_id"],
|
| 245 |
+
caption=f"β
<b>Approved</b> by {admin_name}. No further action needed."
|
| 246 |
+
)
|
| 247 |
+
except Exception:
|
| 248 |
+
pass
|
| 249 |
+
else:
|
| 250 |
+
await callback_query.answer("Could not approve β no pending payment found.", show_alert=True)
|
| 251 |
+
|
| 252 |
+
elif action == "reject":
|
| 253 |
+
success = await db.reject_payment(target_user_id)
|
| 254 |
+
if success:
|
| 255 |
+
await client.send_message(
|
| 256 |
+
target_user_id,
|
| 257 |
+
"β <b>Payment Rejected</b>\n\nYour recent payment submission was rejected by the admin. Please contact the admin or try submitting again."
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
# Update acting admin's message
|
| 261 |
+
status_caption = f"β <b>Rejected</b> by {admin_name}."
|
| 262 |
+
await callback_query.message.edit_caption(status_caption)
|
| 263 |
+
|
| 264 |
+
# Update all OTHER admins' copies
|
| 265 |
+
acting_msg_id = callback_query.message.id
|
| 266 |
+
for am in admin_messages:
|
| 267 |
+
if am["message_id"] == acting_msg_id:
|
| 268 |
+
continue
|
| 269 |
+
try:
|
| 270 |
+
await client.edit_message_caption(
|
| 271 |
+
chat_id=am["chat_id"],
|
| 272 |
+
message_id=am["message_id"],
|
| 273 |
+
caption=f"β <b>Rejected</b> by {admin_name}. No further action needed."
|
| 274 |
+
)
|
| 275 |
+
except Exception:
|
| 276 |
+
pass
|
| 277 |
+
else:
|
| 278 |
+
await callback_query.answer("Could not reject β no pending payment found.", show_alert=True)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@Client.on_message(filters.command("status"))
|
| 282 |
+
async def check_status(client: Client, message: Message):
|
| 283 |
+
if not Telegram.SUBSCRIPTION:
|
| 284 |
+
return
|
| 285 |
+
|
| 286 |
+
user_id = (message.from_user.id if message.from_user else None) or (message.sender_chat.id if message.sender_chat else None) or message.chat.id
|
| 287 |
+
|
| 288 |
+
user = await db.get_user(user_id)
|
| 289 |
+
if not user or user.get("subscription_status") != "active":
|
| 290 |
+
return await message.reply_text("You do not have an active subscription.")
|
| 291 |
+
|
| 292 |
+
expiry = user.get("subscription_expiry")
|
| 293 |
+
if not expiry:
|
| 294 |
+
return await message.reply_text("Error retrieving expiry date.")
|
| 295 |
+
|
| 296 |
+
now = datetime.utcnow()
|
| 297 |
+
if now > expiry:
|
| 298 |
+
return await message.reply_text("Your subscription has expired.")
|
| 299 |
+
|
| 300 |
+
remaining = expiry - now
|
| 301 |
+
days = remaining.days
|
| 302 |
+
hours = remaining.seconds // 3600
|
| 303 |
+
|
| 304 |
+
await message.reply_text(
|
| 305 |
+
f"<b>Subscription Status:</b> Active β
\n"
|
| 306 |
+
f"<b>Expiry Date:</b> {expiry.strftime('%Y-%m-%d %H:%M UTC')}\n"
|
| 307 |
+
f"<b>Time Remaining:</b> {days} days and {hours} hours"
|
| 308 |
+
)
|
README.md
CHANGED
|
@@ -25,6 +25,12 @@
|
|
| 25 |
- [π Introduction](#-introduction)
|
| 26 |
- [β¨ Key Features](#-key-features)
|
| 27 |
- [π New Features](#-new-features)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
- [βοΈ How It Works](#οΈ-how-it-works)
|
| 29 |
- [Overview](#overview)
|
| 30 |
- [Upload Guidelines](#upload-guidelines)
|
|
@@ -41,8 +47,10 @@
|
|
| 41 |
- [π Server](#-server)
|
| 42 |
- [π Update Settings](#-update-settings)
|
| 43 |
- [π Admin Panel](#-admin-panel)
|
|
|
|
| 44 |
- [π§° Additional CDN Bots (Multi-Token System)](#-additional-cdn-bots-multi-token-system)
|
| 45 |
-
|
|
|
|
| 46 |
- [β
Recommended Prerequisites](#-recommended-prerequisites)
|
| 47 |
- [π Heroku Guide](#-heroku-guide)
|
| 48 |
- [π³ VPS Guide (Recommended)](#-vps-guide)
|
|
@@ -66,6 +74,18 @@ This project is a **next-generation Telegram Stremio Media Server** that allows
|
|
| 66 |
- π¬ **IMDB and TMDB Metadata Integration**
|
| 67 |
- βΎοΈ **No File Expiration**
|
| 68 |
- π§ **Admin Panel Support**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
## π New Features
|
|
@@ -270,6 +290,19 @@ All environment variables for this project are defined in the `config.env` file.
|
|
| 270 |
| **`ADMIN_PASSWORD`** | Password for Admin Panel access.|
|
| 271 |
**β οΈ Change from default values for security.**
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
### π§° Additional CDN Bots (Multi-Token System)
|
| 274 |
|
| 275 |
| Variable | Description |
|
|
@@ -287,6 +320,137 @@ To avoid this, you can use **MULTI_TOKEN** system:
|
|
| 287 |
- The system will automatically distribute the load among all these bots!
|
| 288 |
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
# π Deployment Guide
|
| 291 |
|
| 292 |
This guide will help you deploy your **Telegram Stremio Media Server** using either Heroku or a VPS with Docker.
|
|
@@ -552,6 +716,6 @@ If you want to use **only** your **Telegram Stremio Media Server addon** for met
|
|
| 552 |
|
| 553 |
|<img width="80" src="https://avatars.githubusercontent.com/u/113664541">|<img width="80" src="https://avatars.githubusercontent.com/u/13152917">|<img width="80" src="https://avatars.githubusercontent.com/u/14957082">|<img width="80" src="https://raw.githubusercontent.com/vflixa1prime/Readme/main/VFlixPRime.png">|
|
| 554 |
|:---:|:---:|:---:|:---:|
|
| 555 |
-
|[`Karan`](https://github.com/Weebzone)|[`Stremio`](https://github.com/Stremio)|[`ChatGPT`](https://github.com/OPENAI)|[`VFlix Prime`](https://t.me/
|
| 556 |
|Author|Stremio SDK|Refactor|Community Support
|
| 557 |
|
|
|
|
| 25 |
- [π Introduction](#-introduction)
|
| 26 |
- [β¨ Key Features](#-key-features)
|
| 27 |
- [π New Features](#-new-features)
|
| 28 |
+
- [οΏ½ Subscription Management](#-subscription-management)
|
| 29 |
+
- [π³ Subscription Management](#-subscription-management-config)
|
| 30 |
+
- [π Plans](#-subscription-plans)
|
| 31 |
+
- [π€ Bot Payment Flow](#-bot-payment-flow)
|
| 32 |
+
- [ποΈ Access Management](#οΈ-access-management)
|
| 33 |
+
- [π¬ Stremio Addon Integration](#-stremio-addon-integration)
|
| 34 |
- [βοΈ How It Works](#οΈ-how-it-works)
|
| 35 |
- [Overview](#overview)
|
| 36 |
- [Upload Guidelines](#upload-guidelines)
|
|
|
|
| 47 |
- [π Server](#-server)
|
| 48 |
- [π Update Settings](#-update-settings)
|
| 49 |
- [π Admin Panel](#-admin-panel)
|
| 50 |
+
|
| 51 |
- [π§° Additional CDN Bots (Multi-Token System)](#-additional-cdn-bots-multi-token-system)
|
| 52 |
+
|
| 53 |
+
- [οΏ½π Deployment Guide](#-deployment-guide)
|
| 54 |
- [β
Recommended Prerequisites](#-recommended-prerequisites)
|
| 55 |
- [π Heroku Guide](#-heroku-guide)
|
| 56 |
- [π³ VPS Guide (Recommended)](#-vps-guide)
|
|
|
|
| 74 |
- π¬ **IMDB and TMDB Metadata Integration**
|
| 75 |
- βΎοΈ **No File Expiration**
|
| 76 |
- π§ **Admin Panel Support**
|
| 77 |
+
- π³ **Subscription Management** β Plans, payment approval, auto token generation, and expiry enforcement
|
| 78 |
+
- π **Access Management** β View, extend, reduce, revoke, and reassign subscriptions from the admin UI
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
## π New Features
|
| 82 |
+
|
| 83 |
+
- β‘ **Speed Test** β Speed testing added for all bots on each file to optimize streaming performance.
|
| 84 |
+
- π **Improved Load Balancer** β Enhanced load balancing algorithm for better traffic distribution across multiple tokens.
|
| 85 |
+
- π« **Failed Bot Management** β Max failed bots will be marked as shadow or idle for buffer optimization. This is due to some data center bots having rate-limiting constraints.
|
| 86 |
+
- π **Bot-wise Analysis** β Detailed bot performance analytics available in the admin dashboard for monitoring and optimization.
|
| 87 |
+
- π§Ή **Deleted File Detection** β Automatic detection of deleted files on every restart, with admin capability to manually remove them from the database.
|
| 88 |
+
- π οΈ **Additional Admin Features** β Various small enhancements and improvements for administrators.
|
| 89 |
|
| 90 |
|
| 91 |
## π New Features
|
|
|
|
| 290 |
| **`ADMIN_PASSWORD`** | Password for Admin Panel access.|
|
| 291 |
**β οΈ Change from default values for security.**
|
| 292 |
|
| 293 |
+
### π³ Subscription Management Config
|
| 294 |
+
|
| 295 |
+
Enable the subscription feature to gate access to streams behind a paid plan. When `SUBSCRIPTION=True`, every user must have an active subscription to stream content.
|
| 296 |
+
|
| 297 |
+
| Variable | Description |
|
| 298 |
+
| :--- | :--- |
|
| 299 |
+
| **`SUBSCRIPTION`** | Enable (`True`) or disable (`False`) the subscription gate. When enabled, users without an active subscription see an expired message in Stremio instead of streams. *Default: `False`*. |
|
| 300 |
+
| **`SUBSCRIPTION_GROUP_ID`** | Telegram **group/channel ID** where approved subscribers are invited. Users receive an invite link upon payment approval. |
|
| 301 |
+
| **`APPROVER_IDS`** | Comma-separated Telegram user IDs of admins who can **approve or reject** subscription payment requests. |
|
| 302 |
+
| **`SUBSCRIPTION_URL`** | Telegram bot URL (e.g. `https://t.me/your_bot`) shown to expired users in Stremio so they can renew. |
|
| 303 |
+
|
| 304 |
+
> π‘ `SUBSCRIPTION_GROUP_ID` and `APPROVER_IDS` must be set **without quotes** in `config.env`.
|
| 305 |
+
|
| 306 |
### π§° Additional CDN Bots (Multi-Token System)
|
| 307 |
|
| 308 |
| Variable | Description |
|
|
|
|
| 320 |
- The system will automatically distribute the load among all these bots!
|
| 321 |
|
| 322 |
|
| 323 |
+
---
|
| 324 |
+
|
| 325 |
+
# π³ Subscription Management
|
| 326 |
+
|
| 327 |
+
The Subscription Management system allows you to **monetise access** to your Telegram Stremio server. When enabled, users must have an active subscription to stream content.
|
| 328 |
+
|
| 329 |
+
## π Subscription Plans
|
| 330 |
+
|
| 331 |
+
Admins can create and manage subscription plans from the **Admin Panel β Subscription Management** page.
|
| 332 |
+
|
| 333 |
+
Each plan has:
|
| 334 |
+
- **Name** (e.g. `Monthly`, `Quarterly`)
|
| 335 |
+
- **Duration** in days
|
| 336 |
+
- **Price** (for display)
|
| 337 |
+
- **Description**
|
| 338 |
+
|
| 339 |
+
Plans are stored in MongoDB and can be added, edited, or deleted at any time without restarting.
|
| 340 |
+
|
| 341 |
+
---
|
| 342 |
+
|
| 343 |
+
## π€ Bot Payment Flow
|
| 344 |
+
|
| 345 |
+
Users interact with the bot to subscribe:
|
| 346 |
+
|
| 347 |
+
```
|
| 348 |
+
User β /start β selects plan β sends payment screenshot
|
| 349 |
+
β Approver gets notification β Approve / Reject
|
| 350 |
+
β On Approve:
|
| 351 |
+
β
Subscription saved to DB
|
| 352 |
+
π Stremio addon token auto-generated
|
| 353 |
+
π¨ User receives Stremio install link + group invite
|
| 354 |
+
```
|
| 355 |
+
|
| 356 |
+
**Approver actions** (available to `APPROVER_IDS`):
|
| 357 |
+
|
| 358 |
+
| Button | Action |
|
| 359 |
+
| :--- | :--- |
|
| 360 |
+
| β
Approve | Activates subscription, generates addon token, invites user to group |
|
| 361 |
+
| β Reject | Notifies user with rejection message |
|
| 362 |
+
|
| 363 |
+
---
|
| 364 |
+
|
| 365 |
+
## ποΈ Access Management
|
| 366 |
+
|
| 367 |
+
The **Admin Panel β Access Management** page gives admins full control over all users and their addon tokens.
|
| 368 |
+
|
| 369 |
+
### Columns Shown
|
| 370 |
+
|
| 371 |
+
| Column | Description |
|
| 372 |
+
| :--- | :--- |
|
| 373 |
+
| Status | π’ Active / π΄ Expired |
|
| 374 |
+
| User | Display name or `User {id}` |
|
| 375 |
+
| Addon Link | Stremio install URL + copy button |
|
| 376 |
+
| Created | Token creation date |
|
| 377 |
+
| Expires | Subscription expiry date |
|
| 378 |
+
| Actions | Buttons for managing the user |
|
| 379 |
+
|
| 380 |
+
### Action Buttons
|
| 381 |
+
|
| 382 |
+
| Button | Description |
|
| 383 |
+
| :--- | :--- |
|
| 384 |
+
| π
**Assign** | Assign or extend a subscription plan (adds days) |
|
| 385 |
+
| β **Extend** | Add extra days to an active subscription |
|
| 386 |
+
| β **Reduce** | Subtract days from an active subscription |
|
| 387 |
+
| π« **Revoke** | Wipe subscription entirely (marks expired) |
|
| 388 |
+
| ποΈ **Del Token** | Delete the addon token only (user still subscribed) |
|
| 389 |
+
| π **Link User ID** | Link an old/orphan token to a Telegram user ID to enable management |
|
| 390 |
+
|
| 391 |
+
> π‘ Manually created (old) tokens that have no linked user ID show a **π Link User ID** button. Once linked, all action buttons become available.
|
| 392 |
+
|
| 393 |
+
### Search & Filtering
|
| 394 |
+
|
| 395 |
+
- π Search by user name or ID
|
| 396 |
+
- Filter by status: All / Active / Expired
|
| 397 |
+
- Pagination with configurable page size
|
| 398 |
+
|
| 399 |
+
---
|
| 400 |
+
|
| 401 |
+
## π¬ Stremio Addon Integration
|
| 402 |
+
|
| 403 |
+
### Per-User Addon Token
|
| 404 |
+
|
| 405 |
+
Each user gets a **unique addon token** automatically generated on payment approval. Their Stremio addon URL is:
|
| 406 |
+
|
| 407 |
+
```
|
| 408 |
+
https://your-domain.com/stremio/{token}/manifest.json
|
| 409 |
+
```
|
| 410 |
+
|
| 411 |
+
### Dynamic Manifest
|
| 412 |
+
|
| 413 |
+
The addon manifest updates dynamically per user:
|
| 414 |
+
|
| 415 |
+
| Scenario | Addon Name | Description |
|
| 416 |
+
| :--- | :--- | :--- |
|
| 417 |
+
| Active, has expiry | `Telegram β Expires 28 Mar 2026` | π
Subscription active until 28 Mar 2026 |
|
| 418 |
+
| Active, no expiry | `Telegram β Active` | β
Subscription active |
|
| 419 |
+
| Default (no subscription mode) | `Telegram` | Standard description |
|
| 420 |
+
|
| 421 |
+
The manifest `version` encodes the expiry date β when an admin extends or revokes a subscription, the version changes and Stremio detects an update.
|
| 422 |
+
|
| 423 |
+
### Expired Stream
|
| 424 |
+
|
| 425 |
+
When a user's subscription expires, instead of streams they see:
|
| 426 |
+
|
| 427 |
+
```json
|
| 428 |
+
{
|
| 429 |
+
"name": "π« Subscription Expired",
|
| 430 |
+
"title": "Your subscription has expired.\nRenew via the bot to continue watching.",
|
| 431 |
+
"url": "https://t.me/your_bot" β SUBSCRIPTION_URL from config
|
| 432 |
+
}
|
| 433 |
+
```
|
| 434 |
+
|
| 435 |
+
Clicking the stream name opens the bot directly for renewal.
|
| 436 |
+
|
| 437 |
+
### Configure & Reinstall Page
|
| 438 |
+
|
| 439 |
+
Every addon has a **Configure page** at:
|
| 440 |
+
|
| 441 |
+
```
|
| 442 |
+
https://your-domain.com/stremio/{token}/configure
|
| 443 |
+
```
|
| 444 |
+
|
| 445 |
+
This page shows:
|
| 446 |
+
- User name, subscription status, expiry date
|
| 447 |
+
- **β‘ Install / Update in Stremio** button (Stremio Web install flow)
|
| 448 |
+
- Manual install steps + **π Copy URL** button
|
| 449 |
+
|
| 450 |
+
The βοΈ gear icon in Stremio opens this page so users can reinstall after an admin updates their subscription.
|
| 451 |
+
|
| 452 |
+
---
|
| 453 |
+
|
| 454 |
# π Deployment Guide
|
| 455 |
|
| 456 |
This guide will help you deploy your **Telegram Stremio Media Server** using either Heroku or a VPS with Docker.
|
|
|
|
| 716 |
|
| 717 |
|<img width="80" src="https://avatars.githubusercontent.com/u/113664541">|<img width="80" src="https://avatars.githubusercontent.com/u/13152917">|<img width="80" src="https://avatars.githubusercontent.com/u/14957082">|<img width="80" src="https://raw.githubusercontent.com/vflixa1prime/Readme/main/VFlixPRime.png">|
|
| 718 |
|:---:|:---:|:---:|:---:|
|
| 719 |
+
|[`Karan`](https://github.com/Weebzone)|[`Stremio`](https://github.com/Stremio)|[`ChatGPT`](https://github.com/OPENAI)|[`VFlix Prime`](https://t.me/vflixprime2)|
|
| 720 |
|Author|Stremio SDK|Refactor|Community Support
|
| 721 |
|
pyproject.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
[project]
|
| 2 |
name = "Telegram-Stremio"
|
| 3 |
-
version = "2.
|
| 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 = "2.1.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"
|
sample_config.env
CHANGED
|
@@ -1,34 +1,24 @@
|
|
| 1 |
# Startup Config
|
| 2 |
-
API_ID
|
| 3 |
-
API_HASH
|
| 4 |
-
BOT_TOKEN
|
| 5 |
-
HELPER_BOT_TOKEN
|
| 6 |
-
OWNER_ID
|
| 7 |
-
REPLACE_MODE
|
| 8 |
-
HIDE_CATALOG
|
| 9 |
-
PARALLEL
|
| 10 |
-
PRE_FETCH
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
#
|
| 24 |
-
|
| 25 |
-
UPSTREAM_BRANCH = "master"
|
| 26 |
-
|
| 27 |
-
# Admin Pannel
|
| 28 |
-
ADMIN_USERNAME = "fyvio"
|
| 29 |
-
ADMIN_PASSWORD = "fyvio"
|
| 30 |
-
|
| 31 |
-
# Additional CDN Bots
|
| 32 |
-
# MULTI_TOKEN1 = ""
|
| 33 |
-
|
| 34 |
-
|
|
|
|
| 1 |
# Startup Config
|
| 2 |
+
API_ID=""
|
| 3 |
+
API_HASH=""
|
| 4 |
+
BOT_TOKEN=""
|
| 5 |
+
HELPER_BOT_TOKEN=""
|
| 6 |
+
OWNER_ID=""
|
| 7 |
+
REPLACE_MODE=""
|
| 8 |
+
HIDE_CATALOG=""
|
| 9 |
+
PARALLEL="4"
|
| 10 |
+
PRE_FETCH="3"
|
| 11 |
+
AUTH_CHANNEL=""
|
| 12 |
+
DATABASE=""
|
| 13 |
+
TMDB_API=""
|
| 14 |
+
BASE_URL=""
|
| 15 |
+
PORT="8001"
|
| 16 |
+
UPSTREAM_REPO="https://github.com/weebzone/Telegram-Stremio"
|
| 17 |
+
UPSTREAM_BRANCH="master"
|
| 18 |
+
ADMIN_USERNAME=""
|
| 19 |
+
ADMIN_PASSWORD=""
|
| 20 |
+
SUBSCRIPTION=True #without ""
|
| 21 |
+
SUBSCRIPTION_GROUP_ID= #without ""
|
| 22 |
+
APPROVER_IDS= #without ""
|
| 23 |
+
SUBSCRIPTION_URL="" #telegram bot url like https://t.me/username
|
| 24 |
+
MULTI_TOKEN1=""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
start.sh
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
uv run update.py && uv run -m Backend
|
|
|
|
| 1 |
+
uv run update.py && uv run -m Backend
|
subscription_bot/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "subscription_bot",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "",
|
| 5 |
+
"main": "index.js",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"test": "echo \"Error: no test specified\" && exit 1"
|
| 8 |
+
},
|
| 9 |
+
"keywords": [],
|
| 10 |
+
"author": "",
|
| 11 |
+
"license": "ISC",
|
| 12 |
+
"type": "commonjs"
|
| 13 |
+
}
|
uv.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|