vflixa1prime Karan Adhikari commited on
Commit
e581a8c
Β·
unverified Β·
1 Parent(s): de94e0d

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 CHANGED
@@ -11,4 +11,4 @@ StartTime = time()
11
  USE_DEFAULT_ID: str = None
12
  db = Database()
13
 
14
- __version__ = "2.0.0"
 
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": ADDON_VERSION,
165
- "name": ADDON_NAME,
166
  "logo": "https://i.postimg.cc/XqWnmDXr/Picsart-25-10-09-08-09-45-867.png",
167
- "description": "Streams movies and series from your Telegram.",
168
  "types": ["movie", "series"],
169
  "resources": resources,
170
  "catalogs": catalogs,
171
  "idPrefixes": ["tt"],
172
  "behaviorHints": {
173
- "configurable": False,
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
- if daily_limit and daily_limit > 0:
23
- current_daily_gb = usage.get("daily", {}).get("bytes", 0) / (1024 ** 3)
24
- if current_daily_gb >= daily_limit:
25
- token_data["limit_exceeded"] = "daily"
26
- token_data["limit_video"] = DAILY_LIMIT_VIDEO
27
  return token_data
28
 
29
- if monthly_limit and monthly_limit > 0:
30
- current_monthly_gb = usage.get("monthly", {}).get("bytes", 0) / (1024 ** 3)
31
- if current_monthly_gb >= monthly_limit:
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
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="/" class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Dashboard</a>
69
- <a href="/media/manage" class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Media</a>
70
- <a href="/admin/dashboard" class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Admin</a>
 
 
 
 
 
 
 
71
  </div>
72
  {% endif %}
73
  </div>
74
 
75
  <div class="flex items-center space-x-4">
76
  <!-- Theme Selector -->
77
- <form method="post" action="/set-theme" id="theme-form">
78
- <select name="theme"
79
- onchange="document.getElementById('theme-form').submit()"
80
- class="theme-card border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 text-sm text-text focus:outline-none focus:ring-2 focus:ring-primary">
81
- {% for theme_key, theme_data in themes.items() %}
82
- <option value="{{ theme_key }}" {% if current_theme==theme_key %}selected{% endif %}>
83
- {{ theme_data.name }}
84
- </option>
85
- {% endfor %}
86
- </select>
87
- </form>
 
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
- class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 transition-colors">
94
- Logout
95
- </a>
96
  </div>
97
  {% else %}
98
  <a href="/login"
99
- class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors">
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" class="theme-text-secondary hover:text-primary text-sm transition-colors">Status</a>
120
- <a href="/stremio" class="theme-text-secondary hover:text-primary text-sm transition-colors">
121
- Stremio Guide
122
- </a>
123
  </div>
124
  </div>
125
  </div>
126
  </footer>
127
  </body>
128
- </html>
 
 
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 activeStreams = data.active_streams || [];
673
  const container = document.getElementById('active-streams-container');
 
 
674
 
675
- if (container) {
676
- if (activeStreams.length === 0) {
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
- } else {
683
- const escapeHtml = (unsafe) => {
684
- if (!unsafe) return '';
685
- return unsafe
686
- .toString()
687
- .replace(/&/g, "&amp;")
688
- .replace(/</g, "&lt;")
689
- .replace(/>/g, "&gt;")
690
- .replace(/"/g, "&quot;")
691
- .replace(/'/g, "&#039;");
692
- };
693
-
694
- const formatBytesStr = (bytes) => {
695
- if (bytes === 0) return '0 B';
696
- const k = 1024;
697
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
698
- const i = Math.floor(Math.log(bytes) / Math.log(k));
699
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
700
- };
701
-
702
- container.innerHTML = activeStreams.map(stream => {
703
- let statusColor = 'bg-red-100 text-red-800';
704
- let statusPulse = 'hidden';
705
- if (stream.status === 'active') {
706
- statusColor = 'bg-green-100 text-green-800';
707
- statusPulse = '';
708
- } else if (stream.status === 'cancelled') {
709
- statusColor = 'bg-yellow-100 text-yellow-800';
710
- }
711
 
712
- const streamIdStr = stream.stream_id ? stream.stream_id.substring(0, 8) + '...' : 'Unknown';
713
- const titleStr = (stream.meta && stream.meta.title) ? stream.meta.title : (stream.title || 'Unknown Stream');
714
 
715
- return `
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
- }).join('');
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, "&amp;")
689
+ .replace(/</g, "&lt;")
690
+ .replace(/>/g, "&gt;")
691
+ .replace(/"/g, "&quot;")
692
+ .replace(/'/g, "&#039;");
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
- @Client.on_message(filters.command('start') & filters.private & CustomFilters.owner, group=10)
 
 
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 main Telegram Stremio bot!</b>\n\n'
14
- 'To install the Stremio addon, copy the URL below and add it in the Stremio addons:\n\n'
15
- f'<b>Your Addon URL:</b>\n<code>{addon_url}</code>',
 
 
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
- - [πŸš€ Deployment Guide](#-deployment-guide)
 
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/vflixprime)|
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.0.0"
4
  description = "A powerful, self-hosted Telegram Stremio Media Server built with FastAPI, MongoDB, and PyroFork seamlessly integrated with Stremio for automated media streaming and discovery."
5
  readme = "README.md"
6
  requires-python = ">=3.11"
 
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 = "true"
8
- HIDE_CATALOG = "false"
9
- PARALLEL = "1"
10
- PRE_FETCH = "1"
11
-
12
- # STORAGE
13
- AUTH_CHANNEL = ""
14
- DATABASE = ""
15
-
16
- # API
17
- TMDB_API = ""
18
-
19
- # SERVER
20
- BASE_URL = ""
21
- PORT = "8000"
22
-
23
- # Update
24
- UPSTREAM_REPO = "https://github.com/weebzone/Telegram-Stremio"
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