├── .python-version ├── start.sh ├── heroku.yml ├── Backend ├── helper │ ├── exceptions.py │ ├── custom_filter.py │ ├── pinger.py │ ├── task_manager.py │ ├── encrypt.py │ ├── modal.py │ ├── imdb.py │ ├── pyro.py │ ├── custom_dl.py │ └── metadata.py ├── fastapi │ ├── __init__.py │ ├── security │ │ └── credentials.py │ ├── templates │ │ ├── login.html │ │ ├── public_status.html │ │ ├── base.html │ │ ├── stremio_guide.html │ │ ├── dashboard.html │ │ └── media_management.html │ ├── routes │ │ ├── stream_routes.py │ │ ├── api_routes.py │ │ ├── template_routes.py │ │ └── stremio_routes.py │ ├── main.py │ └── themes.py ├── __init__.py ├── pyrofork │ ├── bot.py │ ├── plugins │ │ ├── log.py │ │ ├── start.py │ │ ├── restart.py │ │ ├── manual.py │ │ ├── reciever.py │ │ └── fix_metadata.py │ └── clients.py ├── logger.py ├── config.py └── __main__.py ├── requirements.txt ├── .gitignore ├── docker-compose.yaml ├── sample_config.env ├── Dockerfile ├── pyproject.toml ├── bump-version.py ├── update.py └── README.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | uv run update.py && uv run -m Backend -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | docker: 3 | web: Dockerfile 4 | -------------------------------------------------------------------------------- /Backend/helper/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidHash(Exception): 2 | message = 'Invalid hash!' 3 | 4 | 5 | class FIleNotFound(Exception): 6 | message = 'File not found!' -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles 2 | fastapi 3 | httpx 4 | motor 5 | parse-torrent-title 6 | pyrofork 7 | python-dotenv 8 | pytz 9 | tgcrypto 10 | themoviedb 11 | uvicorn 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | media/ 3 | *.strm 4 | log.txt 5 | **/__pycache__/ 6 | *.session 7 | *.session-journal 8 | *.session-shm 9 | *.session-wal 10 | /.vscode/ 11 | test.py 12 | config.env 13 | *.pyc 14 | -------------------------------------------------------------------------------- /Backend/fastapi/__init__.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from Backend.config import Telegram 3 | from Backend.fastapi.main import app 4 | 5 | 6 | Port = Telegram.PORT 7 | config = uvicorn.Config(app=app, host='0.0.0.0', port=Port) 8 | server = uvicorn.Server(config) 9 | -------------------------------------------------------------------------------- /Backend/__init__.py: -------------------------------------------------------------------------------- 1 | from Backend.helper.database import Database 2 | from time import time 3 | from datetime import datetime 4 | import pytz 5 | 6 | timezone = pytz.timezone("Asia/Kolkata") 7 | now = datetime.now(timezone) 8 | StartTime = time() 9 | 10 | 11 | USE_DEFAULT_ID: str = None 12 | db = Database() 13 | 14 | __version__ = "1.5.0" 15 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | telegram-stremio: 3 | build: . 4 | container_name: tg_stremio 5 | ports: 6 | - "8000:8000" 7 | volumes: 8 | - ./config.env:/app/config.env 9 | restart: unless-stopped 10 | logging: 11 | driver: "json-file" 12 | options: 13 | max-size: "1m" 14 | max-file: "3" -------------------------------------------------------------------------------- /Backend/helper/custom_filter.py: -------------------------------------------------------------------------------- 1 | from pyrogram.filters import create 2 | from Backend.config import Telegram 3 | 4 | class CustomFilters: 5 | 6 | @staticmethod 7 | async def owner_filter(client, message): 8 | user = message.from_user or message.sender_chat 9 | uid = user.id 10 | return uid == Telegram.OWNER_ID 11 | 12 | owner = create(owner_filter) -------------------------------------------------------------------------------- /sample_config.env: -------------------------------------------------------------------------------- 1 | # Startup Config 2 | API_ID = "" 3 | API_HASH = "" 4 | BOT_TOKEN = "" 5 | HELPER_BOT_TOKEN = "" 6 | OWNER_ID = "" 7 | REPLACE_MODE = "true" 8 | 9 | # STORAGE 10 | AUTH_CHANNEL = "" 11 | DATABASE = "" 12 | 13 | # API 14 | TMDB_API = "" 15 | 16 | # SERVER 17 | BASE_URL = "" 18 | PORT = "8000" 19 | 20 | # Update 21 | UPSTREAM_REPO = "https://github.com/weebzone/Telegram-Stremio" 22 | UPSTREAM_BRANCH = "master" 23 | 24 | # Admin Pannel 25 | ADMIN_USERNAME = "fyvio" 26 | ADMIN_PASSWORD = "fyvio" 27 | 28 | # Additional CDN Bots 29 | # MULTI_TOKEN1 = "" 30 | 31 | 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:debian-slim 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | ENV PYTHONUNBUFFERED=1 5 | ENV LANG=en_US.UTF-8 6 | ENV PATH="/app/.venv/bin:$PATH" 7 | 8 | RUN apt-get update && \ 9 | apt-get install -y --no-install-recommends \ 10 | build-essential \ 11 | bash \ 12 | git \ 13 | curl \ 14 | ca-certificates \ 15 | locales && \ 16 | locale-gen en_US.UTF-8 && \ 17 | rm -rf /var/lib/apt/lists/* 18 | 19 | WORKDIR /app 20 | COPY . . 21 | RUN uv lock 22 | RUN uv sync --locked 23 | RUN chmod +x start.sh 24 | CMD ["bash", "start.sh"] 25 | -------------------------------------------------------------------------------- /Backend/pyrofork/bot.py: -------------------------------------------------------------------------------- 1 | from pyrogram import Client 2 | from Backend.config import Telegram 3 | 4 | 5 | StreamBot = Client( 6 | name='bot', 7 | api_id=Telegram.API_ID, 8 | api_hash=Telegram.API_HASH, 9 | bot_token=Telegram.BOT_TOKEN, 10 | plugins={"root": "Backend/pyrofork/plugins"}, 11 | sleep_threshold=20, 12 | workers=6, 13 | max_concurrent_transmissions=10 14 | ) 15 | 16 | 17 | Helper = Client( 18 | "helper", 19 | api_id=Telegram.API_ID, 20 | api_hash=Telegram.API_HASH, 21 | bot_token=Telegram.HELPER_BOT_TOKEN, 22 | sleep_threshold=20, 23 | workers=6, 24 | max_concurrent_transmissions=10 25 | ) 26 | 27 | 28 | multi_clients = {} 29 | work_loads = {} -------------------------------------------------------------------------------- /Backend/pyrofork/plugins/log.py: -------------------------------------------------------------------------------- 1 | from pyrogram import filters, Client 2 | from pyrogram.types import Message 3 | from os import path as ospath 4 | 5 | from Backend.helper.custom_filter import CustomFilters 6 | 7 | @Client.on_message(filters.command('log') & filters.private & CustomFilters.owner, group=10) 8 | async def log(client: Client, message: Message): 9 | try: 10 | path = ospath.abspath('log.txt') 11 | if not ospath.exists(path): 12 | return await message.reply_text("> ❌ Log file not found.") 13 | 14 | await message.reply_document( 15 | document=path, 16 | quote=True, 17 | disable_notification=True 18 | ) 19 | except Exception as e: 20 | await message.reply_text(f"⚠️ Error: {e}") 21 | print(f"Error in /log: {e}") 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Telegram-Stremio" 3 | version = "1.5.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" 7 | dependencies = [ 8 | "aiofiles>=24.1.0", 9 | "fastapi>=0.115.12", 10 | "httpx>=0.28.1", 11 | "itsdangerous>=2.2.0", 12 | "jinja2>=3.1.6", 13 | "motor>=3.7.0", 14 | "parse-torrent-title>=2.8.1", 15 | "pyrofork>=2.3.61", 16 | "python-dotenv>=1.1.0", 17 | "python-multipart>=0.0.20", 18 | "pytz>=2025.2", 19 | "requests>=2.32.3", 20 | "tgcrypto>=1.2.5", 21 | "themoviedb>=1.0.2", 22 | "uvicorn>=0.34.2", 23 | ] 24 | 25 | [dependency-groups] 26 | dev = [ 27 | "deptry>=0.23.1", 28 | ] 29 | -------------------------------------------------------------------------------- /Backend/helper/pinger.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import traceback 3 | import aiohttp 4 | from Backend.config import Telegram 5 | from Backend.logger import LOGGER 6 | 7 | async def ping(): 8 | 9 | sleep_time = 1200 10 | manifest_url = f"{Telegram.BASE_URL}/stremio/manifest.json" 11 | 12 | while True: 13 | await asyncio.sleep(sleep_time) 14 | try: 15 | async with aiohttp.ClientSession( 16 | timeout=aiohttp.ClientTimeout(total=10) 17 | ) as session: 18 | async with session.get(manifest_url) as resp: 19 | LOGGER.info(f"Pinged manifest URL — Status: {resp.status}") 20 | except asyncio.TimeoutError: 21 | LOGGER.warning("Timeout: Could not connect to manifest URL.") 22 | except Exception: 23 | LOGGER.error("Ping failed:\n" + traceback.format_exc()) 24 | -------------------------------------------------------------------------------- /Backend/pyrofork/plugins/start.py: -------------------------------------------------------------------------------- 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 | 'Welcome to the main Telegram Stremio bot!\n\n' 14 | 'To install the Stremio addon, copy the URL below and add it in the Stremio addons:\n\n' 15 | f'Your Addon URL:\n{addon_url}', 16 | quote=True, 17 | parse_mode=enums.ParseMode.HTML 18 | ) 19 | 20 | except Exception as e: 21 | await message.reply_text(f"⚠️ Error: {e}") 22 | print(f"Error in /start handler: {e}") -------------------------------------------------------------------------------- /Backend/logger.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | from logging import getLogger, FileHandler, StreamHandler, INFO, ERROR, Formatter, basicConfig 3 | from datetime import datetime 4 | 5 | IST = pytz.timezone("Asia/Kolkata") 6 | 7 | class ISTFormatter(Formatter): 8 | def formatTime(self, record, datefmt=None): 9 | dt = datetime.fromtimestamp(record.created, IST) 10 | return dt.strftime(datefmt or "%d-%b-%y %I:%M:%S %p") 11 | 12 | file_handler = FileHandler("log.txt") 13 | stream_handler = StreamHandler() 14 | formatter = ISTFormatter("[%(asctime)s] [%(levelname)s] - %(message)s", "%d-%b-%y %I:%M:%S %p") 15 | file_handler.setFormatter(formatter) 16 | stream_handler.setFormatter(formatter) 17 | 18 | basicConfig( 19 | handlers=[file_handler, stream_handler], 20 | level=INFO 21 | ) 22 | 23 | getLogger("httpx").setLevel(ERROR) 24 | getLogger("pyrogram").setLevel(ERROR) 25 | getLogger("fastapi").setLevel(ERROR) 26 | 27 | 28 | LOGGER = getLogger(__name__) 29 | LOGGER.setLevel(INFO) 30 | 31 | LOGGER.info("Logger initialized with IST timezone.") 32 | -------------------------------------------------------------------------------- /Backend/config.py: -------------------------------------------------------------------------------- 1 | from os import getenv, path 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv(path.join(path.dirname(path.dirname(__file__)), "config.env")) 5 | 6 | class Telegram: 7 | API_ID = int(getenv("API_ID", "0")) 8 | API_HASH = getenv("API_HASH", "") 9 | BOT_TOKEN = getenv("BOT_TOKEN", "") 10 | HELPER_BOT_TOKEN = getenv("HELPER_BOT_TOKEN", "") 11 | 12 | BASE_URL = getenv("BASE_URL", "").rstrip('/') 13 | PORT = int(getenv("PORT", "8000")) 14 | 15 | AUTH_CHANNEL = [channel.strip() for channel in (getenv("AUTH_CHANNEL") or "").split(",") if channel.strip()] 16 | DATABASE = [db.strip() for db in (getenv("DATABASE") or "").split(",") if db.strip()] 17 | 18 | TMDB_API = getenv("TMDB_API", "") 19 | 20 | UPSTREAM_REPO = getenv("UPSTREAM_REPO", "") 21 | UPSTREAM_BRANCH = getenv("UPSTREAM_BRANCH", "") 22 | 23 | OWNER_ID = int(getenv("OWNER_ID", "5422223708")) 24 | REPLACE_MODE = getenv("REPLACE_MODE", "true").lower() == "true" 25 | 26 | ADMIN_USERNAME = getenv("ADMIN_USERNAME", "fyvio") 27 | ADMIN_PASSWORD = getenv("ADMIN_PASSWORD", "fyvio") 28 | 29 | -------------------------------------------------------------------------------- /Backend/fastapi/security/credentials.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException, Request 2 | from fastapi.security import HTTPBearer 3 | from Backend.config import Telegram 4 | from typing import Optional 5 | import hashlib 6 | 7 | ADMIN_PASSWORD_HASH = hashlib.sha256(Telegram.ADMIN_PASSWORD.encode()).hexdigest() 8 | 9 | security = HTTPBearer(auto_error=False) 10 | 11 | def verify_password(password: str) -> bool: 12 | return hashlib.sha256(password.encode()).hexdigest() == ADMIN_PASSWORD_HASH 13 | 14 | def verify_credentials(username: str, password: str) -> bool: 15 | return username == Telegram.ADMIN_USERNAME and verify_password(password) 16 | 17 | def is_authenticated(request: Request) -> bool: 18 | return request.session.get("authenticated", False) 19 | 20 | def require_auth(request: Request): 21 | if not is_authenticated(request): 22 | raise HTTPException(status_code=401, detail="Authentication required") 23 | return True 24 | 25 | def get_current_user(request: Request) -> Optional[str]: 26 | if is_authenticated(request): 27 | return request.session.get("username") 28 | return None 29 | -------------------------------------------------------------------------------- /Backend/helper/task_manager.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | from pyrogram.errors import FloodWait 3 | from Backend.logger import LOGGER 4 | from Backend.pyrofork.bot import Helper 5 | 6 | async def edit_message(chat_id: int, msg_id: int, new_caption: str): 7 | try: 8 | await Helper.edit_message_caption( 9 | chat_id=chat_id, 10 | message_id=msg_id, 11 | caption=new_caption 12 | ) 13 | await sleep(2) 14 | except FloodWait as e: 15 | LOGGER.warning(f"FloodWait for {e.value} seconds while editing message {msg_id} in {chat_id}") 16 | await sleep(e.value) 17 | except Exception as e: 18 | LOGGER.error(f"Error while editing message {msg_id} in {chat_id}: {e}") 19 | 20 | async def delete_message(chat_id: int, msg_id: int): 21 | try: 22 | await Helper.delete_messages( 23 | chat_id=chat_id, 24 | message_ids=msg_id 25 | ) 26 | await sleep(2) 27 | LOGGER.info(f"Deleted message {msg_id} in {chat_id}") 28 | except FloodWait as e: 29 | LOGGER.warning(f"FloodWait for {e.value} seconds while deleting message {msg_id} in {chat_id}") 30 | await sleep(e.value) 31 | except Exception as e: 32 | LOGGER.error(f"Error while deleting message {msg_id} in {chat_id}: {e}") 33 | -------------------------------------------------------------------------------- /Backend/pyrofork/plugins/restart.py: -------------------------------------------------------------------------------- 1 | from pyrogram import filters, Client, enums 2 | from pyrogram.types import Message 3 | from Backend.helper.custom_filter import CustomFilters 4 | from Backend.logger import LOGGER 5 | from asyncio import create_subprocess_exec, gather 6 | from aiofiles import open as aiopen 7 | from os import execl as osexecl 8 | import shutil 9 | 10 | @Client.on_message(filters.command('restart') & filters.private & CustomFilters.owner, group=10) 11 | async def restart(client: Client, message: Message): 12 | try: 13 | restart_message = await message.reply_text( 14 | '
⚙️ Restarting Backend API... \n\n✨ Please wait as we bring everything back online! 🚀
', 15 | quote=True, 16 | parse_mode=enums.ParseMode.HTML 17 | ) 18 | 19 | proc1 = await create_subprocess_exec('uv', 'run', 'update.py') 20 | await gather(proc1.wait()) 21 | 22 | async with aiopen(".restartmsg", "w") as f: 23 | await f.write(f"{restart_message.chat.id}\n{restart_message.id}\n") 24 | 25 | LOGGER.info("Restarting the bot using uv package manager...") 26 | 27 | uv_path = shutil.which("uv") 28 | if uv_path: 29 | osexecl(uv_path, uv_path, "run", "-m", "Backend") 30 | else: 31 | raise RuntimeError("uv not found in PATH.") 32 | 33 | except Exception as e: 34 | LOGGER.error(f"Error during restart: {e}") 35 | await message.reply_text("**❌ Failed to restart. Check logs for details.**") 36 | 37 | -------------------------------------------------------------------------------- /Backend/pyrofork/plugins/manual.py: -------------------------------------------------------------------------------- 1 | import Backend 2 | from Backend.helper.custom_filter import CustomFilters 3 | from pyrogram import filters, Client, enums 4 | from pyrogram.types import Message 5 | from Backend.logger import LOGGER 6 | 7 | 8 | @Client.on_message(filters.command('set') & filters.private & CustomFilters.owner, group=10) 9 | async def manual(client: Client, message: Message): 10 | try: 11 | command = message.text.split(maxsplit=1) 12 | 13 | if len(command) == 2: 14 | url = command[1].strip() 15 | Backend.USE_DEFAULT_ID = url 16 | 17 | await message.reply_text( 18 | f"✅ Default IMDB/TMDB URL Set!\n\n" 19 | f"Now the bot will use this URL for any files you send:\n" 20 | f"{Backend.USE_DEFAULT_ID}\n\n" 21 | f"Instructions:\n" 22 | f"1. Forward the related movie or TV show files to your channel.\n" 23 | f"2. Once all files are uploaded, clear the default URL by sending /set without any URL.", 24 | quote=True, 25 | parse_mode=enums.ParseMode.HTML 26 | ) 27 | else: 28 | Backend.USE_DEFAULT_ID = None 29 | await message.reply_text( 30 | "✅ Default IMDB/TMDB URL Removed!\n\n" 31 | "You can now manually upload files without linking to a default IMDB URL.", 32 | quote=True, 33 | parse_mode=enums.ParseMode.HTML 34 | ) 35 | 36 | except Exception as e: 37 | LOGGER.error(f"Error in /set handler: {e}") 38 | await message.reply_text(f"⚠️ An error occurred: {e}") 39 | 40 | -------------------------------------------------------------------------------- /Backend/helper/encrypt.py: -------------------------------------------------------------------------------- 1 | import zlib 2 | import json 3 | import asyncio 4 | from concurrent.futures import ThreadPoolExecutor 5 | 6 | executor = ThreadPoolExecutor() 7 | 8 | def compress_data(data): 9 | return zlib.compress(data.encode(), level=zlib.Z_BEST_COMPRESSION) 10 | 11 | def decompress_data(data): 12 | return zlib.decompress(data).decode() 13 | 14 | def base62_encode(data): 15 | BASE62_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 16 | num = int.from_bytes(data, 'big') 17 | base62 = [] 18 | while num: 19 | num, rem = divmod(num, 62) 20 | base62.append(BASE62_ALPHABET[rem]) 21 | return ''.join(reversed(base62)) or '0' 22 | 23 | def base62_decode(data): 24 | BASE62_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 25 | num = 0 26 | for char in data: 27 | num = num * 62 + BASE62_ALPHABET.index(char) 28 | return num.to_bytes((num.bit_length() + 7) // 8, 'big') or b'\0' 29 | 30 | async def async_compress_data(data): 31 | loop = asyncio.get_running_loop() 32 | return await loop.run_in_executor(executor, compress_data, data) 33 | 34 | async def async_decompress_data(data): 35 | loop = asyncio.get_running_loop() 36 | return await loop.run_in_executor(executor, decompress_data, data) 37 | 38 | async def async_base62_encode(data): 39 | loop = asyncio.get_running_loop() 40 | return await loop.run_in_executor(executor, base62_encode, data) 41 | 42 | async def async_base62_decode(data): 43 | loop = asyncio.get_running_loop() 44 | return await loop.run_in_executor(executor, base62_decode, data) 45 | 46 | async def encode_string(data): 47 | json_data = json.dumps(data) 48 | compressed_data = await async_compress_data(json_data) 49 | return await async_base62_encode(compressed_data) 50 | 51 | async def decode_string(encoded_data): 52 | compressed_data = await async_base62_decode(encoded_data) 53 | json_data = await async_decompress_data(compressed_data) 54 | return json.loads(json_data) 55 | -------------------------------------------------------------------------------- /Backend/pyrofork/clients.py: -------------------------------------------------------------------------------- 1 | from asyncio import gather, create_task 2 | from pyrogram import Client 3 | from Backend.logger import LOGGER 4 | from Backend.config import Telegram 5 | from Backend.pyrofork.bot import multi_clients, work_loads, StreamBot 6 | from os import environ 7 | 8 | class TokenParser: 9 | @staticmethod 10 | def parse_from_env(): 11 | tokens = { 12 | c + 1: t 13 | for c, (_, t) in enumerate( 14 | filter( 15 | lambda n: n[0].startswith("MULTI_TOKEN"), 16 | sorted(environ.items()) 17 | ) 18 | ) 19 | } 20 | return tokens 21 | 22 | async def start_client(client_id, token): 23 | try: 24 | LOGGER.info(f"Starting - Bot Client {client_id}") 25 | client = await Client( 26 | name=str(client_id), 27 | api_id=Telegram.API_ID, 28 | api_hash=Telegram.API_HASH, 29 | bot_token=token, 30 | sleep_threshold=100, 31 | no_updates=True, 32 | in_memory=True 33 | ).start() 34 | work_loads[client_id] = 0 35 | return client_id, client 36 | except Exception as e: 37 | LOGGER.error(f"Failed to start Client - {client_id} Error: {e}", exc_info=True) 38 | return None 39 | 40 | async def initialize_clients(): 41 | multi_clients[0], work_loads[0] = StreamBot, 0 42 | all_tokens = TokenParser.parse_from_env() 43 | if not all_tokens: 44 | LOGGER.info("No additional Bot Clients found, Using default client") 45 | return 46 | 47 | tasks = [create_task(start_client(i, token)) for i, token in all_tokens.items()] 48 | clients = await gather(*tasks) 49 | clients = {client_id: client for client_id, client in clients if client} 50 | multi_clients.update(clients) 51 | 52 | if len(multi_clients) != 1: 53 | LOGGER.info(f"Multi-Client Mode Enabled with {len(multi_clients)} clients") 54 | else: 55 | LOGGER.info("No additional clients were initialized, using default client") 56 | 57 | -------------------------------------------------------------------------------- /bump-version.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from pathlib import Path 4 | 5 | def bump_version(version: str, part: str) -> str: 6 | major, minor, patch = map(int, version.split(".")) 7 | if part == "patch": 8 | patch += 1 9 | elif part == "minor": 10 | minor += 1 11 | patch = 0 12 | elif part == "major": 13 | major += 1 14 | minor = patch = 0 15 | else: 16 | raise ValueError(f"Unknown part to bump: {part}") 17 | return f"{major}.{minor}.{patch}" 18 | 19 | def update_pyproject(path: Path, new_version: str): 20 | content = path.read_text() 21 | updated_content = re.sub( 22 | r'version\s*=\s*"[0-9]+\.[0-9]+\.[0-9]+"', 23 | f'version = "{new_version}"', 24 | content 25 | ) 26 | path.write_text(updated_content) 27 | print(f"Updated pyproject.toml to version {new_version}") 28 | 29 | def update_init(path: Path, new_version: str): 30 | content = path.read_text() 31 | updated_content = re.sub( 32 | r'__version__\s*=\s*"[0-9]+\.[0-9]+\.[0-9]+"', 33 | f'__version__ = "{new_version}"', 34 | content 35 | ) 36 | path.write_text(updated_content) 37 | print(f"Updated Backend/__init__.py to version {new_version}") 38 | 39 | def main(part: str = "patch"): 40 | pyproject_path = Path("pyproject.toml") 41 | init_path = Path("Backend/__init__.py") 42 | 43 | if not pyproject_path.exists() or not init_path.exists(): 44 | print("Error: pyproject.toml or Backend/__init__.py not found.") 45 | sys.exit(1) 46 | 47 | # Read current version from pyproject.toml 48 | content = pyproject_path.read_text() 49 | match = re.search(r'version\s*=\s*"([0-9]+\.[0-9]+\.[0-9]+)"', content) 50 | if not match: 51 | print("Error: version not found in pyproject.toml") 52 | sys.exit(1) 53 | 54 | current_version = match.group(1) 55 | new_version = bump_version(current_version, part) 56 | 57 | update_pyproject(pyproject_path, new_version) 58 | update_init(init_path, new_version) 59 | 60 | if __name__ == "__main__": 61 | part = sys.argv[1] if len(sys.argv) > 1 else "patch" # default bump is patch 62 | main(part) 63 | -------------------------------------------------------------------------------- /update.py: -------------------------------------------------------------------------------- 1 | from logging import FileHandler, StreamHandler, INFO, ERROR, Formatter, basicConfig, error as log_error, info as log_info 2 | from os import path as ospath, environ 3 | from pathlib import Path 4 | from subprocess import run as srun, PIPE 5 | from dotenv import load_dotenv 6 | from datetime import datetime 7 | import pytz 8 | 9 | IST = pytz.timezone("Asia/Kolkata") 10 | 11 | class ISTFormatter(Formatter): 12 | def formatTime(self, record, datefmt=None): 13 | dt = datetime.fromtimestamp(record.created, IST) 14 | return dt.strftime(datefmt or "%d-%b-%y %I:%M:%S %p") 15 | 16 | log_file = "log.txt" 17 | if ospath.exists(log_file): 18 | with open(log_file, "w") as f: 19 | f.truncate(0) 20 | 21 | file_handler = FileHandler(log_file) 22 | stream_handler = StreamHandler() 23 | 24 | formatter = ISTFormatter("[%(asctime)s] [%(levelname)s] - %(message)s", "%d-%b-%y %I:%M:%S %p") 25 | file_handler.setFormatter(formatter) 26 | stream_handler.setFormatter(formatter) 27 | 28 | basicConfig(handlers=[file_handler, stream_handler], level=INFO) 29 | 30 | load_dotenv("config.env") 31 | 32 | UPSTREAM_REPO = environ.get("UPSTREAM_REPO", "").strip() or None 33 | UPSTREAM_BRANCH = environ.get("UPSTREAM_BRANCH", "").strip() or "master" 34 | 35 | if UPSTREAM_REPO: 36 | if Path(".git").exists(): 37 | srun(["rm", "-rf", ".git"]) 38 | 39 | update_cmd = ( 40 | f"git init -q && " 41 | f"git config --global user.email 'doc.adhikari@gmail.com' && " 42 | f"git config --global user.name 'weebzone' && " 43 | f"git add . && git commit -sm 'update' -q && " 44 | f"git remote add origin {UPSTREAM_REPO} && " 45 | f"git fetch origin -q && " 46 | f"git reset --hard origin/{UPSTREAM_BRANCH} -q" 47 | ) 48 | 49 | update = srun(update_cmd, shell=True) 50 | repo = UPSTREAM_REPO.strip("/").split("/") 51 | repo_url = f"https://github.com/{repo[-2]}/{repo[-1]}" 52 | log_info(f"UPSTREAM_REPO: {repo_url} | UPSTREAM_BRANCH: {UPSTREAM_BRANCH}") 53 | 54 | if update.returncode == 0: 55 | log_info("Successfully updated with latest commits!!") 56 | else: 57 | log_error("❌ Update failed! Retry or ask for support.") 58 | -------------------------------------------------------------------------------- /Backend/fastapi/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Login - Telegram Stremio{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |

Admin Login

10 |

Sign in to access the admin panel

11 |
12 | 13 | {% if error %} 14 |
15 | {{ error }} 16 |
17 | {% endif %} 18 | 19 |
20 |
21 | 22 | 24 |
25 | 26 |
27 | 28 | 30 |
31 | 32 | 36 |
37 | 38 |
39 |

40 | Public pages: 41 | System Status | 42 | Stremio Guide 43 |

44 |
45 |
46 |
47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /Backend/helper/modal.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | from pydantic import BaseModel, Field 4 | 5 | # --------------------------- 6 | # Quality Detail Schema 7 | # --------------------------- 8 | class QualityDetail(BaseModel): 9 | quality: str 10 | id: str 11 | name: str 12 | size: str 13 | 14 | 15 | # --------------------------- 16 | # Episode Schema 17 | # --------------------------- 18 | class Episode(BaseModel): 19 | episode_number: int 20 | title: str 21 | episode_backdrop: Optional[str] = None 22 | overview: Optional[str] = None 23 | released: Optional[str] = None 24 | telegram: Optional[List[QualityDetail]] 25 | 26 | 27 | # --------------------------- 28 | # Season Schema 29 | # --------------------------- 30 | class Season(BaseModel): 31 | season_number: int 32 | episodes: List[Episode] = Field(default_factory=list) 33 | 34 | 35 | # --------------------------- 36 | # TV Show Schema 37 | # --------------------------- 38 | class TVShowSchema(BaseModel): 39 | tmdb_id: Optional[int] = None 40 | imdb_id: Optional[str] = None 41 | db_index: int 42 | title: str 43 | genres: Optional[List[str]] = None 44 | description: Optional[str] = None 45 | rating: Optional[float] = None 46 | release_year: Optional[int] = None 47 | poster: Optional[str] = None 48 | backdrop: Optional[str] = None 49 | logo: Optional[str] = None 50 | cast: Optional[List[str]] = None 51 | runtime: Optional[str] = None 52 | media_type: str 53 | updated_on: datetime = Field(default_factory=datetime.utcnow) 54 | seasons: List[Season] = Field(default_factory=list) 55 | 56 | 57 | # --------------------------- 58 | # Movie Schema 59 | # --------------------------- 60 | class MovieSchema(BaseModel): 61 | tmdb_id: Optional[int] = None 62 | imdb_id: Optional[str] = None 63 | db_index: int 64 | title: str 65 | genres: Optional[List[str]] = None 66 | description: Optional[str] = None 67 | rating: Optional[float] = None 68 | release_year: Optional[int] = None 69 | poster: Optional[str] = None 70 | backdrop: Optional[str] = None 71 | logo: Optional[str] = None 72 | cast: Optional[List[str]] = None 73 | runtime: Optional[str] = None 74 | media_type: str 75 | updated_on: datetime = Field(default_factory=datetime.utcnow) 76 | telegram: Optional[List[QualityDetail]] 77 | -------------------------------------------------------------------------------- /Backend/__main__.py: -------------------------------------------------------------------------------- 1 | from asyncio import get_event_loop, sleep as asleep 2 | import asyncio 3 | import logging 4 | from traceback import format_exc 5 | from pyrogram import idle 6 | from Backend import __version__, db 7 | from Backend.helper.pinger import ping 8 | from Backend.logger import LOGGER 9 | from Backend.fastapi import server 10 | from Backend.helper.pyro import restart_notification, setup_bot_commands 11 | from Backend.pyrofork.bot import Helper, StreamBot 12 | from Backend.pyrofork.clients import initialize_clients 13 | 14 | loop = get_event_loop() 15 | 16 | async def start_services(): 17 | try: 18 | LOGGER.info(f"Initializing Telegram-Stremio v-{__version__}") 19 | await asleep(1.2) 20 | 21 | await db.connect() 22 | await asleep(1.2) 23 | 24 | await StreamBot.start() 25 | StreamBot.username = StreamBot.me.username 26 | LOGGER.info(f"Bot Client : [@{StreamBot.username}]") 27 | await asleep(1.2) 28 | 29 | await Helper.start() 30 | Helper.username = Helper.me.username 31 | LOGGER.info(f"Helper Bot Client : [@{Helper.username}]") 32 | await asleep(1.2) 33 | 34 | LOGGER.info("Initializing Multi Clients...") 35 | await initialize_clients() 36 | await asleep(2) 37 | 38 | await setup_bot_commands(StreamBot) 39 | await asleep(2) 40 | 41 | LOGGER.info('Initializing Telegram-Stremio Web Server...') 42 | await restart_notification() 43 | loop.create_task(server.serve()) 44 | loop.create_task(ping()) 45 | 46 | LOGGER.info("Telegram-Stremio Started Successfully!") 47 | await idle() 48 | except Exception: 49 | LOGGER.error("Error during startup:\n" + format_exc()) 50 | 51 | async def stop_services(): 52 | try: 53 | LOGGER.info("Stopping services...") 54 | 55 | pending_tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] 56 | for task in pending_tasks: 57 | task.cancel() 58 | 59 | await asyncio.gather(*pending_tasks, return_exceptions=True) 60 | 61 | await StreamBot.stop() 62 | await Helper.stop() 63 | 64 | await db.disconnect() 65 | 66 | LOGGER.info("Services stopped successfully.") 67 | except Exception: 68 | LOGGER.error("Error during shutdown:\n" + format_exc()) 69 | 70 | if __name__ == '__main__': 71 | try: 72 | loop.run_until_complete(start_services()) 73 | except KeyboardInterrupt: 74 | LOGGER.info('Service Stopping...') 75 | except Exception: 76 | LOGGER.error(format_exc()) 77 | finally: 78 | loop.run_until_complete(stop_services()) 79 | loop.stop() 80 | logging.shutdown() 81 | -------------------------------------------------------------------------------- /Backend/pyrofork/plugins/reciever.py: -------------------------------------------------------------------------------- 1 | from asyncio import create_task, sleep as asleep, Queue, Lock 2 | import Backend 3 | from Backend.helper.task_manager import edit_message 4 | from Backend.logger import LOGGER 5 | from Backend import db 6 | from Backend.config import Telegram 7 | from Backend.helper.pyro import clean_filename, get_readable_file_size, remove_urls 8 | from Backend.helper.metadata import metadata 9 | from pyrogram import filters, Client 10 | from pyrogram.types import Message 11 | from pyrogram.errors import FloodWait 12 | from pyrogram.enums.parse_mode import ParseMode 13 | 14 | 15 | file_queue = Queue() 16 | db_lock = Lock() 17 | 18 | async def process_file(): 19 | while True: 20 | metadata_info, channel, msg_id, size, title = await file_queue.get() 21 | async with db_lock: 22 | updated_id = await db.insert_media(metadata_info, channel=channel, msg_id=msg_id, size=size, name=title) 23 | if updated_id: 24 | LOGGER.info(f"{metadata_info['media_type']} updated with ID: {updated_id}") 25 | else: 26 | LOGGER.info("Update failed due to validation errors.") 27 | file_queue.task_done() 28 | 29 | for _ in range(1): 30 | create_task(process_file()) 31 | 32 | 33 | @Client.on_message(filters.channel & (filters.document | filters.video)) 34 | async def file_receive_handler(client: Client, message: Message): 35 | if str(message.chat.id) in Telegram.AUTH_CHANNEL: 36 | try: 37 | if message.video or (message.document and message.document.mime_type.startswith("video/")): 38 | file = message.video or message.document 39 | title = message.caption or file.file_name 40 | msg_id = message.id 41 | size = get_readable_file_size(file.file_size) 42 | channel = str(message.chat.id).replace("-100", "") 43 | 44 | metadata_info = await metadata(clean_filename(title), int(channel), msg_id) 45 | if metadata_info is None: 46 | LOGGER.warning(f"Metadata failed for file: {title} (ID: {msg_id})") 47 | return 48 | 49 | title = remove_urls(title) 50 | if not title.endswith(('.mkv', '.mp4')): 51 | title += '.mkv' 52 | 53 | if Backend.USE_DEFAULT_ID: 54 | new_caption = (message.caption + "\n\n" + Backend.USE_DEFAULT_ID) if message.caption else Backend.USE_DEFAULT_ID 55 | create_task(edit_message( 56 | chat_id=message.chat.id, 57 | msg_id=message.id, 58 | new_caption=new_caption 59 | )) 60 | 61 | await file_queue.put((metadata_info, int(channel), msg_id, size, title)) 62 | else: 63 | await message.reply_text("> Not supported") 64 | except FloodWait as e: 65 | LOGGER.info(f"Sleeping for {str(e.value)}s") 66 | await asleep(e.value) 67 | await message.reply_text( 68 | text=f"Got Floodwait of {str(e.value)}s", 69 | disable_web_page_preview=True, 70 | parse_mode=ParseMode.MARKDOWN 71 | ) 72 | else: 73 | await message.reply_text("> Channel is not in AUTH_CHANNEL") 74 | 75 | -------------------------------------------------------------------------------- /Backend/fastapi/templates/public_status.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}System Status - Telegram Stremio{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 8 |
9 |

System Status

10 |

Current status of Telegram Stremio services

11 |
12 | 13 | 14 |
15 |
16 |
17 | 18 |
19 |

Backend Status

20 |

{{ stats.status|title }}

21 |
22 | 23 |
24 |
25 | 26 |
27 |

Uptime

28 |

{{ stats.uptime }}

29 |
30 | 31 |
32 |
33 | v 34 |
35 |

Version

36 |

{{ stats.version }}

37 |
38 |
39 | 40 | 41 |
42 |

Service Status

43 |
44 |
45 | API Endpoints 46 | Active 47 |
48 |
49 | Stremio Addon 50 | Active 51 |
52 |
53 | Database 54 | Connected 55 |
56 |
57 |
58 | 59 | {% if not is_authenticated %} 60 | 61 |
62 |

Administrator Access

63 |

Login to access the full admin dashboard

64 | 65 | Admin Login 66 | 67 |
68 | {% endif %} 69 |
70 | {% endblock %} 71 | -------------------------------------------------------------------------------- /Backend/fastapi/routes/stream_routes.py: -------------------------------------------------------------------------------- 1 | import math 2 | import secrets 3 | import mimetypes 4 | from typing import Tuple 5 | from fastapi import APIRouter, Request, HTTPException 6 | from fastapi.responses import StreamingResponse 7 | 8 | from Backend.helper.encrypt import decode_string 9 | from Backend.helper.exceptions import InvalidHash 10 | from Backend.helper.custom_dl import ByteStreamer 11 | from Backend.pyrofork.bot import StreamBot, work_loads, multi_clients 12 | 13 | router = APIRouter(tags=["Streaming"]) 14 | class_cache = {} 15 | 16 | 17 | def parse_range_header(range_header: str, file_size: int) -> Tuple[int, int]: 18 | if not range_header: 19 | return 0, file_size - 1 20 | try: 21 | range_value = range_header.replace("bytes=", "") 22 | from_str, until_str = range_value.split("-") 23 | from_bytes = int(from_str) 24 | until_bytes = int(until_str) if until_str else file_size - 1 25 | except Exception as e: 26 | raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}") 27 | 28 | if (until_bytes > file_size - 1) or (from_bytes < 0) or (until_bytes < from_bytes): 29 | raise HTTPException( 30 | status_code=416, 31 | detail="Requested Range Not Satisfiable", 32 | headers={"Content-Range": f"bytes */{file_size}"}, 33 | ) 34 | 35 | return from_bytes, until_bytes 36 | 37 | 38 | @router.get("/dl/{id}/{name}") 39 | @router.head("/dl/{id}/{name}") 40 | async def stream_handler(request: Request, id: str, name: str): 41 | decoded_data = await decode_string(id) 42 | if not decoded_data.get("msg_id"): 43 | raise HTTPException(status_code=400, detail="Missing id") 44 | 45 | chat_id = f"-100{decoded_data['chat_id']}" 46 | message = await StreamBot.get_messages(int(chat_id), int(decoded_data["msg_id"])) 47 | file = message.video or message.document 48 | file_hash = file.file_unique_id[:6] 49 | 50 | return await media_streamer( 51 | request, 52 | chat_id=int(chat_id), 53 | id=int(decoded_data["msg_id"]), 54 | secure_hash=file_hash 55 | ) 56 | 57 | 58 | async def media_streamer( 59 | request: Request, 60 | chat_id: int, 61 | id: int, 62 | secure_hash: str, 63 | ) -> StreamingResponse: 64 | range_header = request.headers.get("Range", "") 65 | index = min(work_loads, key=work_loads.get) 66 | faster_client = multi_clients[index] 67 | 68 | tg_connect = class_cache.get(faster_client) 69 | if not tg_connect: 70 | tg_connect = ByteStreamer(faster_client) 71 | class_cache[faster_client] = tg_connect 72 | 73 | file_id = await tg_connect.get_file_properties(chat_id=chat_id, message_id=id) 74 | if file_id.unique_id[:6] != secure_hash: 75 | raise InvalidHash 76 | 77 | file_size = file_id.file_size 78 | from_bytes, until_bytes = parse_range_header(range_header, file_size) 79 | 80 | chunk_size = 1024 * 1024 81 | offset = from_bytes - (from_bytes % chunk_size) 82 | first_part_cut = from_bytes - offset 83 | last_part_cut = (until_bytes % chunk_size) + 1 84 | req_length = until_bytes - from_bytes + 1 85 | part_count = math.ceil(until_bytes / chunk_size) - math.floor(offset / chunk_size) 86 | 87 | body = tg_connect.yield_file( 88 | file_id, index, offset, first_part_cut, last_part_cut, part_count, chunk_size 89 | ) 90 | 91 | file_name = file_id.file_name or f"{secrets.token_hex(2)}.unknown" 92 | mime_type = file_id.mime_type or mimetypes.guess_type(file_name)[0] or "application/octet-stream" 93 | if not file_id.file_name and "/" in mime_type: 94 | file_name = f"{secrets.token_hex(2)}.{mime_type.split('/')[1]}" 95 | 96 | headers = { 97 | "Content-Type": mime_type, 98 | "Content-Length": str(req_length), 99 | "Content-Disposition": f'inline; filename="{file_name}"', 100 | "Accept-Ranges": "bytes", 101 | "Cache-Control": "public, max-age=3600, immutable", 102 | "Access-Control-Allow-Origin": "*", 103 | "Access-Control-Expose-Headers": "Content-Length, Content-Range, Accept-Ranges", 104 | } 105 | 106 | if range_header: 107 | headers["Content-Range"] = f"bytes {from_bytes}-{until_bytes}/{file_size}" 108 | status_code = 206 109 | else: 110 | status_code = 200 111 | 112 | return StreamingResponse( 113 | status_code=status_code, 114 | content=body, 115 | headers=headers, 116 | media_type=mime_type, 117 | ) -------------------------------------------------------------------------------- /Backend/fastapi/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}Telegram Stremio{% endblock %} 7 | 8 | 9 | 10 | 27 | 35 | 36 | 37 | 38 | 77 | 78 | 79 |
80 | {% block content %}{% endblock %} 81 |
82 | 83 | 84 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /Backend/helper/imdb.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import re 3 | import asyncio 4 | from typing import Optional, Dict, Any 5 | 6 | BASE_URL = "https://v3-cinemeta.strem.io" 7 | 8 | _client: Optional[httpx.AsyncClient] = None 9 | _client_lock = asyncio.Lock() 10 | 11 | 12 | async def _get_client() -> httpx.AsyncClient: 13 | global _client 14 | async with _client_lock: 15 | if _client is None or _client.is_closed: 16 | _client = httpx.AsyncClient( 17 | timeout=15.0, 18 | follow_redirects=True 19 | ) 20 | return _client 21 | 22 | 23 | def extract_first_year(year_string) -> int: 24 | if not year_string: 25 | return 0 26 | year_str = str(year_string) 27 | year_match = re.search(r'(\d{4})', year_str) 28 | if year_match: 29 | return int(year_match.group(1)) 30 | return 0 31 | 32 | 33 | async def search_title(query: str, type: str) -> Optional[Dict[str, Any]]: 34 | """ 35 | Query Cinemeta search endpoint for a title. 36 | type = 'tvSeries' or 'movie' (your code uses 'tvSeries' for TV) 37 | """ 38 | client = await _get_client() 39 | cinemeta_type = "series" if type == "tvSeries" else type 40 | url = f"{BASE_URL}/catalog/{cinemeta_type}/imdb/search={query}.json" 41 | try: 42 | resp = await client.get(url) 43 | if resp.status_code != 200: 44 | return None 45 | data = resp.json() 46 | if data and 'metas' in data and data['metas']: 47 | meta = data['metas'][0] 48 | return { 49 | 'id': meta.get('imdb_id', meta.get('id', '')), 50 | 'type': type, 51 | 'title': meta.get('name', ''), 52 | 'year': meta.get('releaseInfo', ''), 53 | 'poster': meta.get('poster', '') 54 | } 55 | return None 56 | except Exception: 57 | return None 58 | 59 | 60 | async def get_detail(imdb_id: str, media_type: str) -> Optional[Dict[str, Any]]: 61 | client = await _get_client() 62 | cinemeta_type = "series" if media_type in ["tvSeries", "tv"] else "movie" 63 | 64 | try: 65 | url = f"{BASE_URL}/meta/{cinemeta_type}/{imdb_id}.json" 66 | resp = await client.get(url) 67 | 68 | if resp.status_code != 200: 69 | return None 70 | 71 | data = resp.json() 72 | meta = data.get("meta") 73 | if not meta: 74 | return None 75 | 76 | # ---- Extract year ---- 77 | year_value = 0 78 | for field in ["year", "releaseInfo", "released"]: 79 | if meta.get(field): 80 | year_value = extract_first_year(meta[field]) 81 | if year_value: 82 | break 83 | 84 | return { 85 | "id": meta.get("imdb_id") or meta.get("id"), 86 | "moviedb_id": meta.get("moviedb_id"), 87 | "type": meta.get("type", media_type), 88 | "title": meta.get("name", ""), 89 | "plot": meta.get("description", ""), 90 | "genre": meta.get("genres") or meta.get("genre", []), 91 | "releaseDetailed": {"year": year_value}, 92 | "rating": {"star": float(meta.get("imdbRating", 0) or 0)}, 93 | "poster": meta.get("poster", ""), 94 | "background": meta.get("background", ""), 95 | "logo": meta.get("logo", ""), 96 | "runtime": meta.get("runtime") or 0, 97 | "director": meta.get("director", []), 98 | "cast": meta.get("cast", []), 99 | "videos": meta.get("videos", []) 100 | } 101 | 102 | except Exception: 103 | return None 104 | 105 | 106 | async def get_season(imdb_id: str, season_id: int, episode_id: int) -> Optional[Dict[str, Any]]: 107 | """ 108 | Return episode meta for a specific season/episode using Cinemeta series endpoint. 109 | """ 110 | client = await _get_client() 111 | try: 112 | url = f"{BASE_URL}/meta/series/{imdb_id}.json" 113 | resp = await client.get(url) 114 | if resp.status_code != 200: 115 | return None 116 | data = resp.json() 117 | if 'meta' in data and 'videos' in data['meta']: 118 | for video in data['meta']['videos']: 119 | if (str(video.get('season', '')) == str(season_id) and 120 | str(video.get('episode', '')) == str(episode_id)): 121 | return { 122 | 'title': video.get('title', f'Episode {episode_id}'), 123 | 'no': str(episode_id), 124 | 'season': str(season_id), 125 | 'image': video.get('thumbnail', ''), 126 | 'plot': video.get('overview', ''), 127 | 'released': video.get('released', '') 128 | } 129 | return None 130 | except Exception: 131 | return None 132 | -------------------------------------------------------------------------------- /Backend/fastapi/templates/stremio_guide.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Stremio Setup Guide - Telegram Stremio{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 8 |
9 |

Stremio Addon Setup

10 |

Install the Telegram Stremio addon to access your media through Stremio

11 |
12 | 13 | 14 |
15 |
16 |
17 |
18 | 1 19 |
20 |
21 |

Copy the Addon URL

22 |

Copy the manifest URL below to your clipboard

23 |
24 | {{ request.base_url }}stremio/manifest.json 25 | 27 |
28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 | 2 36 |
37 |
38 |

Open Stremio

39 |

Launch the Stremio application on your device

40 |
41 |
42 |
43 | 44 |
45 |
46 |
47 | 3 48 |
49 |
50 |

Navigate to Addons

51 |

Go to the "Addons" section in Stremio's main menu

52 |
53 |
54 |
55 | 56 |
57 |
58 |
59 | 4 60 |
61 |
62 |

Install the Addon

63 |

Paste the URL in the "Add Addon" field and click install

64 |
65 | Tip: Make sure your device can access this server's IP address on the network 66 |
67 |
68 |
69 |
70 |
71 | 72 | 73 |
74 |

Additional Information

75 |
76 |
77 |

Network Requirements

78 |

Both Stremio and this server must be on the same network or the server must be accessible via internet

79 |
80 |
81 |

Supported Content

82 |

The addon provides access to movies and TV shows in your media collection

83 |
84 |
85 |

Updates

86 |

The addon will automatically sync with your media library changes

87 |
88 |
89 |
90 |
91 | 92 | 114 | {% endblock %} 115 | -------------------------------------------------------------------------------- /Backend/fastapi/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request, Form, Depends, Query 2 | from fastapi.responses import HTMLResponse, RedirectResponse 3 | from fastapi.middleware.cors import CORSMiddleware 4 | from fastapi.staticfiles import StaticFiles 5 | from starlette.middleware.sessions import SessionMiddleware 6 | from Backend import __version__ 7 | from Backend.fastapi.security.credentials import require_auth 8 | from Backend.fastapi.routes.stream_routes import router as stream_router 9 | 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 | ) 14 | from Backend.fastapi.routes.api_routes import ( 15 | list_media_api, delete_media_api, update_media_api, 16 | delete_movie_quality_api, delete_tv_quality_api, 17 | delete_tv_episode_api, delete_tv_season_api 18 | ) 19 | 20 | app = FastAPI( 21 | title="Telegram Stremio Media Server", 22 | 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.", 23 | version=__version__ 24 | ) 25 | 26 | # --- Middleware Setup --- 27 | app.add_middleware(SessionMiddleware, secret_key="f6d2e3b9a0f43d9a2e6a56b2d3175cd9c05bbfe31d95ed2a7306b57cb1a8b6f0") 28 | app.add_middleware( 29 | CORSMiddleware, 30 | allow_origins=["*"], 31 | allow_credentials=True, 32 | allow_methods=["*"], 33 | allow_headers=["*"], 34 | ) 35 | 36 | try: 37 | app.mount("/static", StaticFiles(directory="Backend/fastapi/static"), name="static") 38 | except Exception: 39 | pass 40 | 41 | # --- Include existing API routers --- 42 | app.include_router(stream_router) 43 | app.include_router(stremio_router) 44 | 45 | # --- Public Routes (No Authentication Required) --- 46 | @app.get("/login", response_class=HTMLResponse) 47 | async def login_get(request: Request): 48 | return await login_page(request) 49 | 50 | @app.post("/login", response_class=HTMLResponse) 51 | async def login_post_route(request: Request, username: str = Form(...), password: str = Form(...)): 52 | return await login_post(request, username, password) 53 | 54 | @app.get("/logout") 55 | async def logout_route(request: Request): 56 | return await logout(request) 57 | 58 | @app.post("/set-theme") 59 | async def set_theme_route(request: Request, theme: str = Form(...)): 60 | return await set_theme(request, theme) 61 | 62 | @app.get("/status", response_class=HTMLResponse) 63 | async def public_status(request: Request): 64 | return await public_status_page(request) 65 | 66 | @app.get("/stremio", response_class=HTMLResponse) 67 | async def stremio_guide(request: Request): 68 | return await stremio_guide_page(request) 69 | 70 | # --- Protected Routes (Authentication Required) --- 71 | @app.get("/", response_class=HTMLResponse) 72 | async def root(request: Request, _: bool = Depends(require_auth)): 73 | return await dashboard_page(request, _) 74 | 75 | @app.get("/media/manage", response_class=HTMLResponse) 76 | async def media_management(request: Request, media_type: str = "movie", _: bool = Depends(require_auth)): 77 | return await media_management_page(request, media_type, _) 78 | 79 | @app.get("/media/edit", response_class=HTMLResponse) 80 | async def edit_media(request: Request, tmdb_id: int, db_index: int, media_type: str, _: bool = Depends(require_auth)): 81 | return await edit_media_page(request, tmdb_id, db_index, media_type, _) 82 | 83 | @app.get("/api/media/list") 84 | async def list_media( 85 | media_type: str = Query("movie", regex="^(movie|tv)$"), 86 | page: int = Query(1, ge=1), 87 | page_size: int = Query(24, ge=1, le=100), 88 | search: str = Query("", max_length=100), 89 | _: bool = Depends(require_auth) 90 | ): 91 | return await list_media_api(media_type, page, page_size, search) 92 | 93 | @app.delete("/api/media/delete") 94 | async def delete_media(tmdb_id: int, db_index: int, media_type: str, _: bool = Depends(require_auth)): 95 | return await delete_media_api(tmdb_id, db_index, media_type) 96 | 97 | @app.put("/api/media/update") 98 | async def update_media(request: Request, tmdb_id: int, db_index: int, media_type: str, _: bool = Depends(require_auth)): 99 | return await update_media_api(request, tmdb_id, db_index, media_type) 100 | 101 | @app.delete("/api/media/delete-quality") 102 | async def delete_movie_quality(tmdb_id: int, db_index: int, id: str, _: bool = Depends(require_auth)): 103 | return await delete_movie_quality_api(tmdb_id, db_index, id) 104 | 105 | @app.delete("/api/media/delete-tv-quality") 106 | async def delete_tv_quality(tmdb_id: int, db_index: int, season: int, episode: int, id: str, _: bool = Depends(require_auth)): 107 | return await delete_tv_quality_api(tmdb_id, db_index, season, episode, id) 108 | 109 | @app.delete("/api/media/delete-tv-episode") 110 | async def delete_tv_episode(tmdb_id: int, db_index: int, season: int, episode: int, _: bool = Depends(require_auth)): 111 | return await delete_tv_episode_api(tmdb_id, db_index, season, episode) 112 | 113 | @app.delete("/api/media/delete-tv-season") 114 | async def delete_tv_season(tmdb_id: int, db_index: int, season: int, _: bool = Depends(require_auth)): 115 | return await delete_tv_season_api(tmdb_id, db_index, season) 116 | 117 | @app.get("/api/system/workloads") 118 | async def get_workloads(_: bool = Depends(require_auth)): 119 | try: 120 | from Backend.pyrofork.bot import work_loads 121 | return { 122 | "loads": { 123 | f"bot{c + 1}": l 124 | for c, (_, l) in enumerate( 125 | sorted(work_loads.items(), key=lambda x: x[1], reverse=True) 126 | ) 127 | } if work_loads else {} 128 | } 129 | except Exception as e: 130 | return {"loads": {}} 131 | 132 | @app.exception_handler(401) 133 | async def auth_exception_handler(request: Request, exc): 134 | return RedirectResponse(url="/login", status_code=302) -------------------------------------------------------------------------------- /Backend/helper/pyro.py: -------------------------------------------------------------------------------- 1 | from pyrogram.file_id import FileId 2 | from typing import Optional 3 | from Backend.logger import LOGGER 4 | from Backend import __version__, now, timezone 5 | from Backend.config import Telegram 6 | from Backend.helper.exceptions import FIleNotFound 7 | from aiofiles import open as aiopen 8 | from aiofiles.os import path as aiopath, remove as aioremove 9 | from pyrogram import Client 10 | from Backend.pyrofork.bot import StreamBot 11 | import re 12 | from pyrogram.types import BotCommand 13 | from pyrogram import enums 14 | 15 | 16 | def is_media(message): 17 | return next((getattr(message, attr) for attr in ["document", "photo", "video", "audio", "voice", "video_note", "sticker", "animation"] if getattr(message, attr)), None) 18 | 19 | 20 | async def get_file_ids(client: Client, chat_id: int, message_id: int) -> Optional[FileId]: 21 | try: 22 | message = await client.get_messages(chat_id, message_id) 23 | if message.empty: 24 | raise FIleNotFound("Message not found or empty") 25 | 26 | if media := is_media(message): 27 | file_id_obj = FileId.decode(media.file_id) 28 | file_unique_id = media.file_unique_id 29 | 30 | setattr(file_id_obj, 'file_name', getattr(media, 'file_name', '')) 31 | setattr(file_id_obj, 'file_size', getattr(media, 'file_size', 0)) 32 | setattr(file_id_obj, 'mime_type', getattr(media, 'mime_type', '')) 33 | setattr(file_id_obj, 'unique_id', file_unique_id) 34 | 35 | return file_id_obj 36 | else: 37 | raise FIleNotFound("No supported media found in message") 38 | except Exception as e: 39 | LOGGER.error(f"Error getting file IDs: {e}") 40 | raise 41 | 42 | 43 | 44 | def get_readable_file_size(size_in_bytes): 45 | size_in_bytes = int(size_in_bytes) if str(size_in_bytes).isdigit() else 0 46 | if not size_in_bytes: 47 | return '0B' 48 | 49 | index, SIZE_UNITS = 0, ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] 50 | while size_in_bytes >= 1024 and index < len(SIZE_UNITS) - 1: 51 | size_in_bytes /= 1024 52 | index += 1 53 | 54 | return f'{size_in_bytes:.2f}{SIZE_UNITS[index]}' if index > 0 else f'{size_in_bytes:.0f}B' 55 | 56 | 57 | def clean_filename(filename): 58 | if not filename: 59 | return "unknown_file" 60 | 61 | pattern = r'_@[A-Za-z]+_|@[A-Za-z]+_|[\[\]\s@]*@[^.\s\[\]]+[\]\[\s@]*' 62 | cleaned_filename = re.sub(pattern, '', filename) 63 | 64 | cleaned_filename = re.sub( 65 | r'(?<=\W)(org|AMZN|DDP|DD|NF|AAC|TVDL|5\.1|2\.1|2\.0|7\.0|7\.1|5\.0|~|\b\w+kbps\b)(?=\W)', 66 | ' ', cleaned_filename, flags=re.IGNORECASE 67 | ) 68 | 69 | cleaned_filename = re.sub(r'\s+', ' ', cleaned_filename).strip().replace(' .', '.') 70 | 71 | return cleaned_filename if cleaned_filename else "unknown_file" 72 | 73 | 74 | def get_readable_time(seconds: int) -> str: 75 | count = 0 76 | readable_time = "" 77 | time_list = [] 78 | time_suffix_list = ["s", "m", "h", " days"] 79 | 80 | while count < 4: 81 | count += 1 82 | if count < 3: 83 | remainder, result = divmod(seconds, 60) 84 | else: 85 | remainder, result = divmod(seconds, 24) 86 | 87 | if seconds == 0 and remainder == 0: 88 | break 89 | 90 | time_list.append(int(result)) 91 | seconds = int(remainder) 92 | 93 | for x in range(len(time_list)): 94 | time_list[x] = str(time_list[x]) + time_suffix_list[x] 95 | 96 | if len(time_list) == 4: 97 | readable_time += time_list.pop() + ", " 98 | 99 | time_list.reverse() 100 | readable_time += ": ".join(time_list) 101 | 102 | return readable_time 103 | 104 | 105 | 106 | def remove_urls(text): 107 | if not text: 108 | return "" 109 | 110 | url_pattern = r'\b(?:https?|ftp):\/\/[^\s/$.?#].[^\s]*' 111 | text_without_urls = re.sub(url_pattern, '', text) 112 | cleaned_text = re.sub(r'\s+', ' ', text_without_urls).strip() 113 | 114 | return cleaned_text 115 | 116 | 117 | 118 | async def restart_notification(): 119 | chat_id, msg_id = 0, 0 120 | try: 121 | if await aiopath.exists(".restartmsg"): 122 | async with aiopen(".restartmsg", "r") as f: 123 | data = await f.readlines() 124 | chat_id, msg_id = map(int, data) 125 | 126 | try: 127 | repo = Telegram.UPSTREAM_REPO.split('/') 128 | UPSTREAM_REPO = f"https://github.com/{repo[-2]}/{repo[-1]}" 129 | await StreamBot.edit_message_text( 130 | chat_id=chat_id, 131 | message_id=msg_id, 132 | text=f"... ♻️ Restart Successfully...! \n\nDate: {now.strftime('%d/%m/%y')}\nTime: {now.strftime('%I:%M:%S %p')}\nTimeZone: {timezone.zone}\n\nRepo: {UPSTREAM_REPO}\nBranch: {Telegram.UPSTREAM_BRANCH}\nVersion: {__version__}", 133 | parse_mode=enums.ParseMode.HTML 134 | ) 135 | except Exception as e: 136 | LOGGER.error(f"Failed to edit restart message: {e}") 137 | 138 | await aioremove(".restartmsg") 139 | 140 | except Exception as e: 141 | LOGGER.error(f"Error in restart_notification: {e}") 142 | 143 | 144 | # Bot commands 145 | commands = [ 146 | BotCommand("start", "🚀 Start the bot"), 147 | BotCommand("set", "🎬 Manually add IMDb metadata"), 148 | BotCommand("fixmetadata", "⚙️ Fix empty fields of Metadata"), 149 | BotCommand("log", "📄 Send the log file"), 150 | BotCommand("restart", "♻️ Restart the bot"), 151 | ] 152 | 153 | 154 | async def setup_bot_commands(bot: Client): 155 | try: 156 | current_commands = await bot.get_bot_commands() 157 | if current_commands: 158 | LOGGER.info(f"Found {len(current_commands)} existing commands. Deleting them...") 159 | await bot.set_bot_commands([]) 160 | 161 | await bot.set_bot_commands(commands) 162 | LOGGER.info("Bot commands updated successfully.") 163 | except Exception as e: 164 | LOGGER.error(f"Error setting up bot commands: {e}") 165 | 166 | -------------------------------------------------------------------------------- /Backend/fastapi/themes.py: -------------------------------------------------------------------------------- 1 | # Theme configurations based on your color palettes 2 | THEMES = { 3 | "purple_gradient": { 4 | "name": "Purple Gradient", 5 | "colors": { 6 | "primary": "#A855F7", 7 | "secondary": "#7C3AED", 8 | "accent": "#C084FC", 9 | "background": "#F8FAFC", 10 | "card": "#FFFFFF", 11 | "text": "#1E293B", 12 | "text_secondary": "#64748B" 13 | }, 14 | "css_classes": "theme-purple-gradient" 15 | }, 16 | "blue_navy": { 17 | "name": "Navy Blue", 18 | "colors": { 19 | "primary": "#1E40AF", 20 | "secondary": "#1E3A8A", 21 | "accent": "#3B82F6", 22 | "background": "#F1F5F9", 23 | "card": "#FFFFFF", 24 | "text": "#1E293B", 25 | "text_secondary": "#475569" 26 | }, 27 | "css_classes": "theme-blue-navy" 28 | }, 29 | "sunset_warm": { 30 | "name": "Sunset Warm", 31 | "colors": { 32 | "primary": "#F59E0B", 33 | "secondary": "#DC2626", 34 | "accent": "#EC4899", 35 | "background": "#FFFBEB", 36 | "card": "#FFFFFF", 37 | "text": "#1F2937", 38 | "text_secondary": "#6B7280" 39 | }, 40 | "css_classes": "theme-sunset-warm" 41 | }, 42 | "ocean_mint": { 43 | "name": "Ocean Mint", 44 | "colors": { 45 | "primary": "#10B981", 46 | "secondary": "#059669", 47 | "accent": "#06B6D4", 48 | "background": "#F0FDF4", 49 | "card": "#FFFFFF", 50 | "text": "#1F2937", 51 | "text_secondary": "#4B5563" 52 | }, 53 | "css_classes": "theme-ocean-mint" 54 | }, 55 | "dark_professional": { 56 | "name": "Dark Professional", 57 | "colors": { 58 | "primary": "#06B6D4", 59 | "secondary": "#0891B2", 60 | "accent": "#22D3EE", 61 | "background": "#0F172A", 62 | "card": "#1E293B", 63 | "text": "#F8FAFC", 64 | "text_secondary": "#CBD5E1" 65 | }, 66 | "css_classes": "theme-dark-professional" 67 | }, 68 | "crimson_elegance": { 69 | "name": "Crimson Elegance", 70 | "colors": { 71 | "primary": "#DC2626", 72 | "secondary": "#991B1B", 73 | "accent": "#F87171", 74 | "background": "#FEF2F2", 75 | "card": "#FFFFFF", 76 | "text": "#1F2937", 77 | "text_secondary": "#6B7280" 78 | }, 79 | "css_classes": "theme-crimson-elegance" 80 | }, 81 | "coral_bliss": { 82 | "name": "Coral Bliss", 83 | "colors": { 84 | "primary": "#FF6B6B", 85 | "secondary": "#EE5A6F", 86 | "accent": "#FFB347", 87 | "background": "#FFF5F5", 88 | "card": "#FFFFFF", 89 | "text": "#2D3748", 90 | "text_secondary": "#718096" 91 | }, 92 | "css_classes": "theme-coral-bliss" 93 | }, 94 | "cyber_neon": { 95 | "name": "Cyber Neon", 96 | "colors": { 97 | "primary": "#00FFF0", 98 | "secondary": "#FF00FF", 99 | "accent": "#00FF88", 100 | "background": "#0A0E27", 101 | "card": "#1A1F3A", 102 | "text": "#FFFFFF", 103 | "text_secondary": "#A0AEC0" 104 | }, 105 | "css_classes": "theme-cyber-neon" 106 | }, 107 | "forest_earth": { 108 | "name": "Forest Earth", 109 | "colors": { 110 | "primary": "#2D5016", 111 | "secondary": "#50623A", 112 | "accent": "#86A789", 113 | "background": "#F5F5DC", 114 | "card": "#FFFFFF", 115 | "text": "#1C1C1C", 116 | "text_secondary": "#5C5C5C" 117 | }, 118 | "css_classes": "theme-forest-earth" 119 | }, 120 | "lavender_dream": { 121 | "name": "Lavender Dream", 122 | "colors": { 123 | "primary": "#B08BBB", 124 | "secondary": "#9370DB", 125 | "accent": "#DDA0DD", 126 | "background": "#FAF5FF", 127 | "card": "#FFFFFF", 128 | "text": "#2D3748", 129 | "text_secondary": "#718096" 130 | }, 131 | "css_classes": "theme-lavender-dream" 132 | }, 133 | "golden_luxury": { 134 | "name": "Golden Luxury", 135 | "colors": { 136 | "primary": "#D4AF37", 137 | "secondary": "#B8860B", 138 | "accent": "#FFD700", 139 | "background": "#FFFEF7", 140 | "card": "#FFFFFF", 141 | "text": "#1A202C", 142 | "text_secondary": "#4A5568" 143 | }, 144 | "css_classes": "theme-golden-luxury" 145 | }, 146 | "arctic_frost": { 147 | "name": "Arctic Frost", 148 | "colors": { 149 | "primary": "#B0E0E6", 150 | "secondary": "#4682B4", 151 | "accent": "#87CEEB", 152 | "background": "#F0F8FF", 153 | "card": "#FFFFFF", 154 | "text": "#1E3A5F", 155 | "text_secondary": "#5B7C99" 156 | }, 157 | "css_classes": "theme-arctic-frost" 158 | }, 159 | "cherry_cola": { 160 | "name": "Cherry Cola", 161 | "colors": { 162 | "primary": "#BF1922", 163 | "secondary": "#8B0000", 164 | "accent": "#DC143C", 165 | "background": "#FFF0F0", 166 | "card": "#FFFFFF", 167 | "text": "#1F2937", 168 | "text_secondary": "#6B7280" 169 | }, 170 | "css_classes": "theme-cherry-cola" 171 | }, 172 | "emerald_tech": { 173 | "name": "Emerald Tech", 174 | "colors": { 175 | "primary": "#047857", 176 | "secondary": "#065F46", 177 | "accent": "#10B981", 178 | "background": "#ECFDF5", 179 | "card": "#FFFFFF", 180 | "text": "#1F2937", 181 | "text_secondary": "#4B5563" 182 | }, 183 | "css_classes": "theme-emerald-tech" 184 | }, 185 | "midnight_carbon": { 186 | "name": "Midnight Carbon", 187 | "colors": { 188 | "primary": "#3B82F6", 189 | "secondary": "#1E40AF", 190 | "accent": "#60A5FA", 191 | "background": "#111827", 192 | "card": "#1F2937", 193 | "text": "#F9FAFB", 194 | "text_secondary": "#D1D5DB" 195 | }, 196 | "css_classes": "theme-midnight-carbon" 197 | } 198 | } 199 | 200 | 201 | def get_theme(theme_name: str = "purple_gradient"): 202 | return THEMES.get(theme_name, THEMES["purple_gradient"]) 203 | 204 | 205 | def get_all_themes(): 206 | return THEMES 207 | -------------------------------------------------------------------------------- /Backend/fastapi/routes/api_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request, Query, HTTPException 2 | from Backend import db 3 | 4 | # --- API Routes for Media Management --- 5 | 6 | async def list_media_api( 7 | media_type: str = Query("movie", regex="^(movie|tv)$"), 8 | page: int = Query(1, ge=1), 9 | page_size: int = Query(24, ge=1, le=100), 10 | search: str = Query("", max_length=100) 11 | ): 12 | try: 13 | if search: 14 | result = await db.search_documents(search, page, page_size) 15 | filtered_results = [item for item in result['results'] if item.get('media_type') == media_type] 16 | total_filtered = len(filtered_results) 17 | start_index = (page - 1) * page_size 18 | end_index = start_index + page_size 19 | paged_results = filtered_results[start_index:end_index] 20 | 21 | return { 22 | "total_count": total_filtered, 23 | "current_page": page, 24 | "total_pages": (total_filtered + page_size - 1) // page_size, 25 | "movies" if media_type == "movie" else "tv_shows": paged_results 26 | } 27 | else: 28 | if media_type == "movie": 29 | return await db.sort_movies([], page, page_size) 30 | else: 31 | return await db.sort_tv_shows([], page, page_size) 32 | except Exception as e: 33 | raise HTTPException(status_code=500, detail=str(e)) 34 | 35 | async def delete_media_api( 36 | tmdb_id: int, 37 | db_index: int, 38 | media_type: str = Query(regex="^(movie|tv)$") 39 | ): 40 | try: 41 | media_type_formatted = "Movie" if media_type == "movie" else "Series" 42 | result = await db.delete_document(media_type_formatted, tmdb_id, db_index) 43 | if result: 44 | return {"message": "Media deleted successfully"} 45 | else: 46 | raise HTTPException(status_code=404, detail="Media not found") 47 | except Exception as e: 48 | raise HTTPException(status_code=500, detail=str(e)) 49 | 50 | async def update_media_api( 51 | request: Request, 52 | tmdb_id: int, 53 | db_index: int, 54 | media_type: str = Query(regex="^(movie|tv)$") 55 | ): 56 | try: 57 | update_data = await request.json() 58 | if 'rating' in update_data and update_data['rating']: 59 | try: 60 | update_data['rating'] = float(update_data['rating']) 61 | except (ValueError, TypeError): 62 | update_data['rating'] = 0.0 63 | 64 | if 'release_year' in update_data and update_data['release_year']: 65 | try: 66 | update_data['release_year'] = int(update_data['release_year']) 67 | except (ValueError, TypeError): 68 | pass 69 | if 'genres' in update_data: 70 | if isinstance(update_data['genres'], str): 71 | update_data['genres'] = [g.strip() for g in update_data['genres'].split(',') if g.strip()] 72 | elif not isinstance(update_data['genres'], list): 73 | update_data['genres'] = [] 74 | 75 | if 'languages' in update_data: 76 | if isinstance(update_data['languages'], str): 77 | update_data['languages'] = [l.strip() for l in update_data['languages'].split(',') if l.strip()] 78 | elif not isinstance(update_data['languages'], list): 79 | update_data['languages'] = [] 80 | if media_type == "movie": 81 | if 'runtime' in update_data and update_data['runtime']: 82 | try: 83 | update_data['runtime'] = int(update_data['runtime']) 84 | except (ValueError, TypeError): 85 | pass 86 | elif media_type == "tv": 87 | if 'total_seasons' in update_data and update_data['total_seasons']: 88 | try: 89 | update_data['total_seasons'] = int(update_data['total_seasons']) 90 | except (ValueError, TypeError): 91 | pass 92 | 93 | if 'total_episodes' in update_data and update_data['total_episodes']: 94 | try: 95 | update_data['total_episodes'] = int(update_data['total_episodes']) 96 | except (ValueError, TypeError): 97 | pass 98 | update_data = {k: v for k, v in update_data.items() if v != ""} 99 | result = await db.update_document(media_type, tmdb_id, db_index, update_data) 100 | if result: 101 | return {"message": "Media updated successfully"} 102 | else: 103 | raise HTTPException(status_code=404, detail="Media not found or no changes made") 104 | 105 | except Exception as e: 106 | raise HTTPException(status_code=500, detail=str(e)) 107 | 108 | async def get_media_details_api( 109 | tmdb_id: int, 110 | db_index: int, 111 | media_type: str = Query(regex="^(movie|tv)$") 112 | ): 113 | try: 114 | result = await db.get_document(media_type, tmdb_id, db_index) 115 | if result: 116 | return result 117 | else: 118 | raise HTTPException(status_code=404, detail="Media not found") 119 | except Exception as e: 120 | raise HTTPException(status_code=500, detail=str(e)) 121 | 122 | async def delete_movie_quality_api(tmdb_id: int, db_index: int, id: str): 123 | try: 124 | result = await db.delete_movie_quality(tmdb_id, db_index, id) 125 | if result: 126 | return {"message": "Quality deleted successfully"} 127 | else: 128 | raise HTTPException(status_code=404, detail="Quality not found") 129 | except Exception as e: 130 | raise HTTPException(status_code=500, detail=str(e)) 131 | 132 | async def delete_tv_quality_api( 133 | tmdb_id: int, db_index: int, season: int, episode: int, id: str 134 | ): 135 | try: 136 | result = await db.delete_tv_quality(tmdb_id, db_index, season, episode, id) 137 | if result: 138 | return {"message": "deleted successfully"} 139 | else: 140 | raise HTTPException(status_code=404, detail="Quality not found") 141 | except Exception as e: 142 | raise HTTPException(status_code=500, detail=str(e)) 143 | 144 | async def delete_tv_episode_api( 145 | tmdb_id: int, db_index: int, season: int, episode: int 146 | ): 147 | try: 148 | result = await db.delete_tv_episode(tmdb_id, db_index, season, episode) 149 | if result: 150 | return {"message": "Episode deleted successfully"} 151 | else: 152 | raise HTTPException(status_code=404, detail="Episode not found") 153 | except Exception as e: 154 | raise HTTPException(status_code=500, detail=str(e)) 155 | 156 | async def delete_tv_season_api(tmdb_id: int, db_index: int, season: int): 157 | try: 158 | result = await db.delete_tv_season(tmdb_id, db_index, season) 159 | if result: 160 | return {"message": "Season deleted successfully"} 161 | else: 162 | raise HTTPException(status_code=404, detail="Season not found") 163 | except Exception as e: 164 | raise HTTPException(status_code=500, detail=str(e)) 165 | -------------------------------------------------------------------------------- /Backend/helper/custom_dl.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pyrogram import utils, raw 3 | from pyrogram.errors import AuthBytesInvalid 4 | from pyrogram.file_id import FileId, FileType, ThumbnailSource 5 | from pyrogram.session import Session, Auth 6 | from typing import Dict, Union 7 | from Backend.logger import LOGGER 8 | from Backend.helper.exceptions import FIleNotFound 9 | from Backend.helper.pyro import get_file_ids 10 | from Backend.pyrofork.bot import work_loads 11 | from pyrogram import Client, utils, raw 12 | 13 | 14 | class ByteStreamer: 15 | def __init__(self, client: Client): 16 | self.clean_timer = 30 * 60 17 | self.client: Client = client 18 | self.__cached_file_ids: Dict[int, FileId] = {} 19 | asyncio.create_task(self.clean_cache()) 20 | 21 | async def get_file_properties(self, chat_id: int, message_id: int) -> FileId: 22 | if message_id not in self.__cached_file_ids: 23 | file_id = await get_file_ids(self.client, int(chat_id), int(message_id)) 24 | if not file_id: 25 | LOGGER.info('Message with ID %s not found!', message_id) 26 | raise FIleNotFound 27 | self.__cached_file_ids[message_id] = file_id 28 | return self.__cached_file_ids[message_id] 29 | 30 | async def yield_file(self, file_id: FileId, index: int, offset: int, first_part_cut: int, last_part_cut: int, part_count: int, chunk_size: int) -> Union[str, None]: # type: ignore 31 | client = self.client 32 | work_loads[index] += 1 33 | LOGGER.debug(f"Starting to yielding file with client {index}.") 34 | media_session = await self.generate_media_session(client, file_id) 35 | current_part = 1 36 | location = await self.get_location(file_id) 37 | try: 38 | r = await media_session.send(raw.functions.upload.GetFile(location=location, offset=offset, limit=chunk_size)) 39 | if isinstance(r, raw.types.upload.File): 40 | while True: 41 | chunk = r.bytes 42 | if not chunk: 43 | break 44 | elif part_count == 1: 45 | yield chunk[first_part_cut:last_part_cut] 46 | elif current_part == 1: 47 | yield chunk[first_part_cut:] 48 | elif current_part == part_count: 49 | yield chunk[:last_part_cut] 50 | else: 51 | yield chunk 52 | 53 | current_part += 1 54 | offset += chunk_size 55 | 56 | if current_part > part_count: 57 | break 58 | 59 | r = await media_session.send( 60 | raw.functions.upload.GetFile( 61 | location=location, offset=offset, limit=chunk_size 62 | ), 63 | ) 64 | except (TimeoutError, AttributeError): 65 | pass 66 | finally: 67 | LOGGER.debug("Finished yielding file with {current_part} parts.") 68 | work_loads[index] -= 1 69 | 70 | async def generate_media_session(self, client: Client, file_id: FileId) -> Session: 71 | media_session = client.media_sessions.get(file_id.dc_id, None) 72 | if media_session is None: 73 | if file_id.dc_id != await client.storage.dc_id(): 74 | media_session = Session( 75 | client, 76 | file_id.dc_id, 77 | await Auth(client, file_id.dc_id, await client.storage.test_mode()).create(), 78 | await client.storage.test_mode(), 79 | is_media=True, 80 | ) 81 | await media_session.start() 82 | for _ in range(6): 83 | exported_auth = await client.invoke(raw.functions.auth.ExportAuthorization(dc_id=file_id.dc_id)) 84 | try: 85 | 86 | await media_session.send(raw.functions.auth.ImportAuthorization(id=exported_auth.id, bytes=exported_auth.bytes)) 87 | break 88 | except AuthBytesInvalid: 89 | LOGGER.debug(f"Invalid authorization bytes for DC {file_id.dc_id}, retrying...") 90 | except OSError: 91 | LOGGER.debug(f"Connection error, retrying...") 92 | await asyncio.sleep(2) 93 | else: 94 | await media_session.stop() 95 | LOGGER.debug(f"Failed to establish media session for DC {file_id.dc_id} after multiple retries") 96 | return None 97 | else: 98 | media_session = Session( 99 | client, 100 | file_id.dc_id, 101 | await client.storage.auth_key(), 102 | await client.storage.test_mode(), 103 | is_media=True, 104 | ) 105 | await media_session.start() 106 | LOGGER.debug(f"Created media session for DC {file_id.dc_id}") 107 | client.media_sessions[file_id.dc_id] = media_session 108 | else: 109 | LOGGER.debug(f"Using cached media session for DC {file_id.dc_id}") 110 | return media_session 111 | 112 | 113 | @staticmethod 114 | async def get_location(file_id: FileId) -> Union[raw.types.InputPhotoFileLocation, raw.types.InputDocumentFileLocation, raw.types.InputPeerPhotoFileLocation]: 115 | file_type = file_id.file_type 116 | if file_type == FileType.CHAT_PHOTO: 117 | if file_id.chat_id > 0: 118 | peer = raw.types.InputPeerUser( 119 | user_id=file_id.chat_id, access_hash=file_id.chat_access_hash) 120 | else: 121 | if file_id.chat_access_hash == 0: 122 | peer = raw.types.InputPeerChat(chat_id=-file_id.chat_id) 123 | else: 124 | peer = raw.types.InputPeerChannel(channel_id=utils.get_channel_id( 125 | file_id.chat_id), access_hash=file_id.chat_access_hash) 126 | location = raw.types.InputPeerPhotoFileLocation(peer=peer, 127 | volume_id=file_id.volume_id, 128 | local_id=file_id.local_id, 129 | big=file_id.thumbnail_source == ThumbnailSource.CHAT_PHOTO_BIG) 130 | elif file_type == FileType.PHOTO: 131 | location = raw.types.InputPhotoFileLocation(id=file_id.media_id, 132 | access_hash=file_id.access_hash, 133 | file_reference=file_id.file_reference, 134 | thumb_size=file_id.thumbnail_size) 135 | else: 136 | location = raw.types.InputDocumentFileLocation(id=file_id.media_id, 137 | access_hash=file_id.access_hash, 138 | file_reference=file_id.file_reference, 139 | thumb_size=file_id.thumbnail_size) 140 | return location 141 | 142 | async def clean_cache(self) -> None: 143 | while True: 144 | await asyncio.sleep(self.clean_timer) 145 | self.__cached_file_ids.clear() 146 | LOGGER.debug("Cleaned the cache") 147 | -------------------------------------------------------------------------------- /Backend/fastapi/routes/template_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request, Form, HTTPException, Depends 2 | from fastapi.responses import RedirectResponse 3 | from fastapi.templating import Jinja2Templates 4 | from Backend.fastapi.security.credentials import verify_credentials, require_auth, is_authenticated, get_current_user 5 | from Backend.fastapi.themes import get_theme, get_all_themes 6 | from Backend import db 7 | from Backend.pyrofork.bot import work_loads, multi_clients, StreamBot 8 | from Backend.helper.pyro import get_readable_time 9 | from Backend import StartTime, __version__ 10 | from time import time 11 | 12 | 13 | templates = Jinja2Templates(directory="Backend/fastapi/templates") 14 | 15 | async def login_page(request: Request): 16 | if is_authenticated(request): 17 | return RedirectResponse(url="/", status_code=302) 18 | 19 | theme_name = request.session.get("theme", "purple_gradient") 20 | theme = get_theme(theme_name) 21 | 22 | return templates.TemplateResponse("login.html", { 23 | "request": request, 24 | "theme": theme, 25 | "themes": get_all_themes(), 26 | "current_theme": theme_name 27 | }) 28 | 29 | async def login_post(request: Request, username: str = Form(...), password: str = Form(...)): 30 | if verify_credentials(username, password): 31 | request.session["authenticated"] = True 32 | request.session["username"] = username 33 | return RedirectResponse(url="/", status_code=302) 34 | else: 35 | theme_name = request.session.get("theme", "purple_gradient") 36 | theme = get_theme(theme_name) 37 | return templates.TemplateResponse("login.html", { 38 | "request": request, 39 | "theme": theme, 40 | "themes": get_all_themes(), 41 | "current_theme": theme_name, 42 | "error": "Invalid credentials" 43 | }) 44 | 45 | async def logout(request: Request): 46 | request.session.clear() 47 | return RedirectResponse(url="/login", status_code=302) 48 | 49 | async def set_theme(request: Request, theme: str = Form(...)): 50 | if theme in get_all_themes(): 51 | request.session["theme"] = theme 52 | return RedirectResponse(url=request.headers.get("referer", "/"), status_code=302) 53 | 54 | async def dashboard_page(request: Request, _: bool = Depends(require_auth)): 55 | theme_name = request.session.get("theme", "purple_gradient") 56 | theme = get_theme(theme_name) 57 | current_user = get_current_user(request) 58 | 59 | try: 60 | db_stats = await db.get_database_stats() 61 | total_movies = sum(stat.get("movie_count", 0) for stat in db_stats) 62 | total_tv_shows = sum(stat.get("tv_count", 0) for stat in db_stats) 63 | 64 | system_stats = { 65 | "server_status": "running", 66 | "uptime": get_readable_time(time() - StartTime), 67 | "telegram_bot": f"@{StreamBot.username}" if StreamBot and StreamBot.username else "@StreamBot", 68 | "connected_bots": len(multi_clients), 69 | "loads": { 70 | f"bot{c + 1}": l 71 | for c, (_, l) in enumerate( 72 | sorted(work_loads.items(), key=lambda x: x[1], reverse=True) 73 | ) 74 | } if work_loads else {}, 75 | "version": __version__, 76 | "movies": total_movies, 77 | "tv_shows": total_tv_shows, 78 | "databases": db_stats, 79 | "total_databases": len(db_stats), 80 | "current_db_index": db.current_db_index 81 | } 82 | except Exception as e: 83 | print(f"Dashboard error: {e}") 84 | system_stats = { 85 | "server_status": "error", 86 | "error": str(e), 87 | "uptime": "N/A", 88 | "telegram_bot": "@StreamBot", 89 | "connected_bots": 0, 90 | "loads": {}, 91 | "version": "1.0.0", 92 | "movies": 0, 93 | "tv_shows": 0, 94 | "databases": [], 95 | "total_databases": 0, 96 | "current_db_index": 1 97 | } 98 | 99 | return templates.TemplateResponse("dashboard.html", { 100 | "request": request, 101 | "theme": theme, 102 | "themes": get_all_themes(), 103 | "current_theme": theme_name, 104 | "current_user": current_user, 105 | "system_stats": system_stats 106 | }) 107 | 108 | 109 | async def media_management_page(request: Request, media_type: str = "movie", _: bool = Depends(require_auth)): 110 | theme_name = request.session.get("theme", "purple_gradient") 111 | theme = get_theme(theme_name) 112 | current_user = get_current_user(request) 113 | 114 | return templates.TemplateResponse("media_management.html", { 115 | "request": request, 116 | "theme": theme, 117 | "themes": get_all_themes(), 118 | "current_theme": theme_name, 119 | "current_user": current_user, 120 | "media_type": media_type 121 | }) 122 | 123 | async def edit_media_page(request: Request, tmdb_id: int, db_index: int, media_type: str, _: bool = Depends(require_auth)): 124 | theme_name = request.session.get("theme", "purple_gradient") 125 | theme = get_theme(theme_name) 126 | current_user = get_current_user(request) 127 | 128 | try: 129 | media_details = await db.get_document(media_type, tmdb_id, db_index) 130 | if not media_details: 131 | raise HTTPException(status_code=404, detail="Media not found") 132 | except Exception as e: 133 | raise HTTPException(status_code=500, detail=str(e)) 134 | 135 | return templates.TemplateResponse("media_edit.html", { 136 | "request": request, 137 | "theme": theme, 138 | "themes": get_all_themes(), 139 | "current_theme": theme_name, 140 | "current_user": current_user, 141 | "tmdb_id": tmdb_id, 142 | "db_index": db_index, 143 | "media_type": media_type, 144 | "media_details": media_details 145 | }) 146 | 147 | async def public_status_page(request: Request): 148 | theme_name = request.session.get("theme", "purple_gradient") 149 | theme = get_theme(theme_name) 150 | 151 | try: 152 | db_stats = await db.get_database_stats() 153 | total_movies = sum(stat.get("movie_count", 0) for stat in db_stats) 154 | total_tv_shows = sum(stat.get("tv_count", 0) for stat in db_stats) 155 | 156 | public_stats = { 157 | "status": "operational", 158 | "uptime": "99.9%", 159 | "total_content": total_movies + total_tv_shows, 160 | "databases_online": len(db_stats) 161 | } 162 | except Exception: 163 | public_stats = { 164 | "status": "maintenance", 165 | "uptime": "N/A", 166 | "total_content": 0, 167 | "databases_online": 0 168 | } 169 | 170 | return templates.TemplateResponse("public_status.html", { 171 | "request": request, 172 | "theme": theme, 173 | "themes": get_all_themes(), 174 | "current_theme": theme_name, 175 | "stats": public_stats, 176 | "is_authenticated": is_authenticated(request) 177 | }) 178 | 179 | async def stremio_guide_page(request: Request): 180 | theme_name = request.session.get("theme", "purple_gradient") 181 | theme = get_theme(theme_name) 182 | 183 | return templates.TemplateResponse("stremio_guide.html", { 184 | "request": request, 185 | "theme": theme, 186 | "themes": get_all_themes(), 187 | "current_theme": theme_name, 188 | "is_authenticated": is_authenticated(request) 189 | }) 190 | -------------------------------------------------------------------------------- /Backend/fastapi/routes/stremio_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException 2 | from typing import Optional 3 | from urllib.parse import unquote 4 | from Backend.config import Telegram 5 | from Backend import db, __version__ 6 | import PTN 7 | from datetime import datetime, timezone, timedelta 8 | 9 | 10 | # --- Configuration --- 11 | BASE_URL = Telegram.BASE_URL 12 | ADDON_NAME = "Telegram" 13 | ADDON_VERSION = __version__ 14 | PAGE_SIZE = 15 15 | 16 | router = APIRouter(prefix="/stremio", tags=["Stremio Addon"]) 17 | 18 | # Define available genres 19 | GENRES = [ 20 | "Action", "Adventure", "Animation", "Biography", "Comedy", 21 | "Crime", "Documentary", "Drama", "Family", "Fantasy", 22 | "History", "Horror", "Music", "Mystery", "Romance", 23 | "Sci-Fi", "Sport", "Thriller", "War", "Western" 24 | ] 25 | 26 | 27 | # --- Helper Functions --- 28 | def convert_to_stremio_meta(item: dict) -> dict: 29 | media_type = "series" if item.get("media_type") == "tv" else "movie" 30 | stremio_id = f"{item.get('tmdb_id')}-{item.get('db_index')}" 31 | 32 | meta = { 33 | "id": stremio_id, 34 | "type": media_type, 35 | "name": item.get("title"), 36 | "poster": item.get("poster") or "", 37 | "logo": item.get("logo") or "", 38 | "year": item.get("release_year"), 39 | "releaseInfo": item.get("release_year"), 40 | "imdb_id": item.get("imdb_id", ""), 41 | "moviedb_id": item.get("tmdb_id", ""), 42 | "background": item.get("backdrop") or "", 43 | "genres": item.get("genres") or [], 44 | "imdbRating": item.get("rating") or "", 45 | "description": item.get("description") or "", 46 | "cast": item.get("cast") or [], 47 | "runtime": item.get("runtime") or "", 48 | } 49 | 50 | return meta 51 | 52 | 53 | def format_stream_details(filename: str, quality: str, size: str) -> tuple[str, str]: 54 | try: 55 | parsed = PTN.parse(filename) 56 | except Exception: 57 | return (f"Telegram {quality}", f"📁 {filename}\n💾 {size}") 58 | 59 | codec_parts = [] 60 | if parsed.get("codec"): 61 | codec_parts.append(f"🎥 {parsed.get('codec')}") 62 | if parsed.get("bitDepth"): 63 | codec_parts.append(f"🌈 {parsed.get('bitDepth')}bit") 64 | if parsed.get("audio"): 65 | codec_parts.append(f"🔊 {parsed.get('audio')}") 66 | if parsed.get("encoder"): 67 | codec_parts.append(f"👤 {parsed.get('encoder')}") 68 | 69 | codec_info = " ".join(codec_parts) if codec_parts else "" 70 | 71 | resolution = parsed.get("resolution", quality) 72 | quality_type = parsed.get("quality", "") 73 | stream_name = f"Telegram {resolution} {quality_type}".strip() 74 | 75 | stream_title_parts = [ 76 | f"📁 {filename}", 77 | f"💾 {size}", 78 | ] 79 | if codec_info: 80 | stream_title_parts.append(codec_info) 81 | 82 | stream_title = "\n".join(stream_title_parts) 83 | return (stream_name, stream_title) 84 | 85 | 86 | def get_resolution_priority(stream_name: str) -> int: 87 | resolution_map = { 88 | "2160p": 2160, "4k": 2160, "uhd": 2160, 89 | "1080p": 1080, "fhd": 1080, 90 | "720p": 720, "hd": 720, 91 | "480p": 480, "sd": 480, 92 | "360p": 360, 93 | } 94 | for res_key, res_value in resolution_map.items(): 95 | if res_key in stream_name.lower(): 96 | return res_value 97 | return 1 98 | 99 | 100 | # --- Routes --- 101 | @router.get("/manifest.json") 102 | async def get_manifest(): 103 | return { 104 | "id": "telegram.media", 105 | "version": ADDON_VERSION, 106 | "name": ADDON_NAME, 107 | "logo": "https://i.postimg.cc/XqWnmDXr/Picsart-25-10-09-08-09-45-867.png", 108 | "description": "Streams movies and series from your Telegram.", 109 | "types": ["movie", "series"], 110 | "resources": ["catalog", "meta", "stream"], 111 | "catalogs": [ 112 | { 113 | "type": "movie", 114 | "id": "latest_movies", 115 | "name": "Latest", 116 | "extra": [ 117 | {"name": "genre", "isRequired": False, "options": GENRES}, 118 | {"name": "skip"} 119 | ], 120 | "extraSupported": ["genre", "skip"] 121 | }, 122 | { 123 | "type": "movie", 124 | "id": "top_movies", 125 | "name": "Popular", 126 | "extra": [ 127 | {"name": "genre", "isRequired": False, "options": GENRES}, 128 | {"name": "skip"}, 129 | {"name": "search", "isRequired": False} 130 | ], 131 | "extraSupported": ["genre", "skip", "search"] 132 | }, 133 | { 134 | "type": "series", 135 | "id": "latest_series", 136 | "name": "Latest", 137 | "extra": [ 138 | {"name": "genre", "isRequired": False, "options": GENRES}, 139 | {"name": "skip"} 140 | ], 141 | "extraSupported": ["genre", "skip"] 142 | }, 143 | { 144 | "type": "series", 145 | "id": "top_series", 146 | "name": "Popular", 147 | "extra": [ 148 | {"name": "genre", "isRequired": False, "options": GENRES}, 149 | {"name": "skip"}, 150 | {"name": "search", "isRequired": False} 151 | ], 152 | "extraSupported": ["genre", "skip", "search"] 153 | } 154 | ], 155 | "idPrefixes": [""], 156 | "behaviorHints": { 157 | "configurable": False, 158 | "configurationRequired": False 159 | } 160 | } 161 | 162 | 163 | @router.get("/catalog/{media_type}/{id}/{extra:path}.json") 164 | @router.get("/catalog/{media_type}/{id}.json") 165 | async def get_catalog(media_type: str, id: str, extra: Optional[str] = None): 166 | if media_type not in ["movie", "series"]: 167 | raise HTTPException(status_code=404, detail="Invalid catalog type") 168 | 169 | genre_filter = None 170 | search_query = None 171 | stremio_skip = 0 172 | 173 | if extra: 174 | params = extra.replace("&", "/").split("/") 175 | for param in params: 176 | if param.startswith("genre="): 177 | genre_filter = unquote(param.removeprefix("genre=")) 178 | elif param.startswith("search="): 179 | search_query = unquote(param.removeprefix("search=")) 180 | elif param.startswith("skip="): 181 | try: 182 | stremio_skip = int(param.removeprefix("skip=")) 183 | except ValueError: 184 | stremio_skip = 0 185 | 186 | page = (stremio_skip // PAGE_SIZE) + 1 187 | 188 | try: 189 | if search_query: 190 | search_results = await db.search_documents(query=search_query, page=page, page_size=PAGE_SIZE) 191 | all_items = search_results.get("results", []) 192 | db_media_type = "tv" if media_type == "series" else "movie" 193 | items = [item for item in all_items if item.get("media_type") == db_media_type] 194 | else: 195 | if "latest" in id: 196 | sort_params = [("updated_on", "desc")] 197 | elif "top" in id: 198 | sort_params = [("rating", "desc")] 199 | else: 200 | sort_params = [("updated_on", "desc")] 201 | 202 | if media_type == "movie": 203 | data = await db.sort_movies(sort_params, page, PAGE_SIZE, genre_filter=genre_filter) 204 | items = data.get("movies", []) 205 | else: 206 | data = await db.sort_tv_shows(sort_params, page, PAGE_SIZE, genre_filter=genre_filter) 207 | items = data.get("tv_shows", []) 208 | except Exception as e: 209 | return {"metas": []} 210 | 211 | metas = [convert_to_stremio_meta(item) for item in items] 212 | return {"metas": metas} 213 | 214 | 215 | @router.get("/meta/{media_type}/{id}.json") 216 | async def get_meta(media_type: str, id: str): 217 | try: 218 | tmdb_id_str, db_index_str = id.split("-") 219 | tmdb_id, db_index = int(tmdb_id_str), int(db_index_str) 220 | except (ValueError, IndexError): 221 | raise HTTPException(status_code=400, detail="Invalid Stremio ID format") 222 | 223 | media = await db.get_media_details(tmdb_id=tmdb_id, db_index=db_index) 224 | if not media: 225 | return {"meta": {}} 226 | 227 | meta_obj = { 228 | "id": id, 229 | "type": "series" if media.get("media_type") == "tv" else "movie", 230 | "name": media.get("title", ""), 231 | "description": media.get("description", ""), 232 | "year": str(media.get("release_year", "")), 233 | "imdbRating": str(media.get("rating", "")), 234 | "genres": media.get("genres", []), 235 | "poster": media.get("poster", ""), 236 | "logo": media.get("logo", ""), 237 | "background": media.get("backdrop", ""), 238 | "imdb_id": media.get("imdb_id", ""), 239 | "releaseInfo": media.get("release_year"), 240 | "moviedb_id": media.get("tmdb_id", ""), 241 | "cast": media.get("cast") or [], 242 | "runtime": media.get("runtime") or "", 243 | 244 | } 245 | 246 | # --- Add Episodes --- 247 | if media_type == "series" and "seasons" in media: 248 | 249 | yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() 250 | 251 | videos = [] 252 | 253 | for season in sorted(media.get("seasons", []), key=lambda s: s.get("season_number")): 254 | for episode in sorted(season.get("episodes", []), key=lambda e: e.get("episode_number")): 255 | 256 | episode_id = f"{id}:{season['season_number']}:{episode['episode_number']}" 257 | 258 | videos.append({ 259 | "id": episode_id, 260 | "title": episode.get("title", f"Episode {episode['episode_number']}"), 261 | "season": season.get("season_number"), 262 | "episode": episode.get("episode_number"), 263 | "overview": episode.get("overview") or "No description available for this episode yet.", 264 | "released": episode.get("released") or yesterday, 265 | "thumbnail": episode.get("episode_backdrop") or "https://raw.githubusercontent.com/weebzone/Colab-Tools/refs/heads/main/no_episode_backdrop.png", 266 | "imdb_id": episode.get("imdb_id") or media.get("imdb_id"), 267 | }) 268 | 269 | meta_obj["videos"] = videos 270 | return {"meta": meta_obj} 271 | 272 | 273 | @router.get("/stream/{media_type}/{id}.json") 274 | async def get_streams(media_type: str, id: str): 275 | try: 276 | parts = id.split(":") 277 | base_id = parts[0] 278 | season_num = int(parts[1]) if len(parts) > 1 else None 279 | episode_num = int(parts[2]) if len(parts) > 2 else None 280 | tmdb_id_str, db_index_str = base_id.split("-") 281 | tmdb_id, db_index = int(tmdb_id_str), int(db_index_str) 282 | 283 | except (ValueError, IndexError): 284 | raise HTTPException(status_code=400, detail="Invalid Stremio ID format") 285 | 286 | media_details = await db.get_media_details( 287 | tmdb_id=tmdb_id, 288 | db_index=db_index, 289 | season_number=season_num, 290 | episode_number=episode_num 291 | ) 292 | 293 | if not media_details or "telegram" not in media_details: 294 | return {"streams": []} 295 | 296 | streams = [] 297 | for quality in media_details.get("telegram", []): 298 | if quality.get("id"): 299 | filename = quality.get('name', '') 300 | quality_str = quality.get('quality', 'HD') 301 | size = quality.get('size', '') 302 | 303 | stream_name, stream_title = format_stream_details(filename, quality_str, size) 304 | 305 | streams.append({ 306 | "name": stream_name, 307 | "title": stream_title, 308 | "url": f"{BASE_URL}/dl/{quality.get('id')}/video.mkv" 309 | }) 310 | 311 | streams.sort(key=lambda s: get_resolution_priority(s.get("name", "")), reverse=True) 312 | return {"streams": streams} 313 | -------------------------------------------------------------------------------- /Backend/fastapi/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Dashboard - Telegram Stremio{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 8 |
9 |
10 |
11 |
12 |

System Overview

13 |
14 |
15 |
16 |
17 | 18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 |
26 | 27 | 28 | 29 |
30 |

System Status

31 |
32 | 33 |
34 |
35 | Uptime 36 | {{ system_stats.uptime or 'N/A' }} 37 |
38 |
39 | Telegram Bot 40 | {{ system_stats.telegram_bot or '@StreamBot' }} 41 |
42 |
43 | Connected Bots 44 | {{ system_stats.connected_bots|default(0) }} 45 |
46 |
47 | Backend Version 48 | v{{ system_stats.version or '1.0.0' }} 49 |
50 |
51 |
52 | 53 | 54 |
55 |
56 |
57 |
58 | 59 | 60 | 61 |
62 |

Bot Workloads

63 |
64 | 65 | 66 | 67 |
68 | 69 | 87 |
88 |
89 |
90 | 91 | 92 |
93 |

Database Overview

94 |
95 | {% if system_stats.databases %} 96 | {% for db_stat in system_stats.databases %} 97 |
98 |
99 |

{{ db_stat.db_name.replace("storage_", "Database ") }}

100 |
101 | 102 | 103 | 104 |
105 |
106 | 107 |
108 |
109 | Movies 110 | {{ "{:,}".format(db_stat.movie_count) }} 111 |
112 |
113 | TV Shows 114 | {{ "{:,}".format(db_stat.tv_count) }} 115 |
116 |
117 |
118 | Storage Used 119 | {{ "%.1f"|format(db_stat.storageSize / 1024 / 1024) }} MB 120 |
121 | {% set storage_percent = (db_stat.storageSize / (500 * 1024 * 1024) * 100) | round %} 122 |
123 |
124 |
125 |
126 |
127 |
128 | {% endfor %} 129 | {% else %} 130 |
No database statistics available
131 | {% endif %} 132 |
133 |
134 | 135 | 136 | 184 | 185 | 186 |
187 |
188 |
189 | 190 | 191 | 192 |
193 |

Stremio Addon

194 |

Install the addon to access media content through Stremio

195 |
196 | 197 | 198 |
199 |

Installation Instructions

200 |
201 |
202 |
1
203 |

Copy the manifest URL below

204 |
205 |
206 |
2
207 |

Open Stremio and go to Addons section

208 |
209 |
210 |
3
211 |

Paste the URL in "Add Addon" field and install

212 |
213 |
214 |
215 | 216 | 217 |
218 | 219 |
220 | 227 | 233 |
234 |
235 |
236 |
237 |
238 | 239 | 273 | {% endblock %} 274 | -------------------------------------------------------------------------------- /Backend/pyrofork/plugins/fix_metadata.py: -------------------------------------------------------------------------------- 1 | import time 2 | import asyncio 3 | from pyrogram import Client, filters 4 | from pyrogram.types import InlineKeyboardMarkup, InlineKeyboardButton 5 | 6 | from Backend import db 7 | from Backend.helper.custom_filter import CustomFilters 8 | from Backend.helper.metadata import fetch_tv_metadata, fetch_movie_metadata 9 | from Backend.logger import LOGGER 10 | 11 | CANCEL_REQUESTED = False 12 | 13 | # ------------------------------- 14 | # Progress Bar Helper 15 | # ------------------------------- 16 | def progress_bar(done, total, length=20): 17 | filled = int(length * (done / total)) if total else length 18 | return f"[{'█' * filled}{'░' * (length - filled)}] {done}/{total}" 19 | 20 | # ------------------------------- 21 | # ETA Helper 22 | # ------------------------------- 23 | def format_eta(seconds): 24 | minutes, sec = divmod(int(seconds), 60) 25 | hours, minutes = divmod(minutes, 60) 26 | 27 | if hours > 0: 28 | return f"{hours}h {minutes}m {sec}s" 29 | if minutes > 0: 30 | return f"{minutes}m {sec}s" 31 | return f"{sec}s" 32 | 33 | # ------------------------------- 34 | # CANCEL BUTTON HANDLER 35 | # ------------------------------- 36 | @Client.on_callback_query(filters.regex("cancel_fix")) 37 | async def cancel_fix(_, query): 38 | global CANCEL_REQUESTED 39 | CANCEL_REQUESTED = True 40 | await query.message.edit_text("❌ Metadata fixing has been cancelled by the user.") 41 | await query.answer("Cancelled") 42 | 43 | # ------------------------------- 44 | # MAIN COMMAND (REWRITTEN - Balanced) 45 | # ------------------------------- 46 | @Client.on_message(filters.command("fixmetadata") & filters.private & CustomFilters.owner, group=10) 47 | async def fix_metadata_handler(_, message): 48 | global CANCEL_REQUESTED 49 | CANCEL_REQUESTED = False 50 | 51 | # ------------------------- 52 | # Gather totals quickly (non-blocking) 53 | # ------------------------- 54 | total_movies = 0 55 | total_tv = 0 56 | for i in range(1, db.current_db_index + 1): 57 | key = f"storage_{i}" 58 | total_movies += await db.dbs[key]["movie"].count_documents({}) 59 | total_tv += await db.dbs[key]["tv"].count_documents({}) 60 | 61 | TOTAL = total_movies + total_tv 62 | DONE = 0 63 | start_time = time.time() 64 | 65 | status = await message.reply_text( 66 | "⏳ Initializing metadata fixing...", 67 | reply_markup=InlineKeyboardMarkup([ 68 | [InlineKeyboardButton("❌ Cancel", callback_data="cancel_fix")] 69 | ]) 70 | ) 71 | 72 | # ------------------------- 73 | # Tunables 74 | # ------------------------- 75 | CONCURRENCY = 20 76 | TASK_BATCH = CONCURRENCY * 2 77 | PROGRESS_INTERVAL = 5.0 78 | 79 | semaphore = asyncio.Semaphore(CONCURRENCY) 80 | meta_cache = {} 81 | last_progress_edit = start_time 82 | 83 | async def cached_fetch_movie(title, year, default_id, encoded_string=None, quality=None): 84 | if default_id: 85 | k = ("movie", str(default_id)) 86 | else: 87 | k = ("movie", f"title::{title or ''}::year::{year or ''}") 88 | 89 | if k in meta_cache: 90 | return meta_cache[k] 91 | 92 | async with semaphore: 93 | try: 94 | meta = await fetch_movie_metadata(title=title, encoded_string=encoded_string, year=year, quality=quality, default_id=default_id) 95 | except Exception as e: 96 | LOGGER.exception(f"fetch_movie_metadata error for {title} ({default_id}): {e}") 97 | meta = None 98 | 99 | meta_cache[k] = meta 100 | return meta 101 | 102 | async def cached_fetch_tv(title, season, episode, year, default_id, encoded_string=None, quality=None): 103 | if default_id: 104 | k = ("tv", str(default_id), int(season), int(episode)) 105 | else: 106 | k = ("tv", f"title::{title or ''}::year::{year or ''}", int(season), int(episode)) 107 | 108 | if k in meta_cache: 109 | return meta_cache[k] 110 | 111 | async with semaphore: 112 | try: 113 | meta = await fetch_tv_metadata(title=title, season=season, episode=episode, 114 | encoded_string=encoded_string, year=year, quality=quality, default_id=default_id) 115 | except Exception as e: 116 | LOGGER.exception(f"fetch_tv_metadata error for {title} S{season}E{episode} ({default_id}): {e}") 117 | meta = None 118 | 119 | meta_cache[k] = meta 120 | return meta 121 | 122 | 123 | async def _safe_update_movie(collection, movie_doc): 124 | nonlocal DONE, last_progress_edit 125 | 126 | if CANCEL_REQUESTED: 127 | return 128 | try: 129 | doc_id = movie_doc.get("_id") 130 | imdb_id = movie_doc.get("imdb_id") 131 | tmdb_id = movie_doc.get("tmdb_id") 132 | title = movie_doc.get("title") 133 | year = movie_doc.get("release_year") 134 | 135 | meta_primary = None 136 | meta_secondary = None 137 | 138 | if imdb_id: 139 | meta_primary = await cached_fetch_movie(title, year, imdb_id) 140 | fetched_tmdb = meta_primary.get("tmdb_id") if meta_primary else None 141 | if (tmdb_id or fetched_tmdb) and (not all_fields_present(meta_primary)): 142 | meta_secondary = await cached_fetch_movie(title, year, (tmdb_id or fetched_tmdb)) 143 | elif tmdb_id: 144 | meta_primary = await cached_fetch_movie(title, year, tmdb_id) 145 | fetched_imdb = meta_primary.get("imdb_id") if meta_primary else None 146 | if fetched_imdb and (not all_fields_present(meta_primary)): 147 | meta_secondary = await cached_fetch_movie(title, year, fetched_imdb) 148 | else: 149 | meta_primary = await cached_fetch_movie(title, year, None) 150 | if meta_primary: 151 | fetched_imdb = meta_primary.get("imdb_id") 152 | fetched_tmdb = meta_primary.get("tmdb_id") 153 | if fetched_imdb and (not all_fields_present(meta_primary)): 154 | meta_secondary = await cached_fetch_movie(title, year, fetched_imdb) 155 | elif fetched_tmdb and (not all_fields_present(meta_primary)): 156 | meta_secondary = await cached_fetch_movie(title, year, fetched_tmdb) 157 | 158 | update_query = {} 159 | current = dict(movie_doc) 160 | 161 | api_map = { 162 | "imdb_id": "imdb_id", 163 | "tmdb_id": "tmdb_id", 164 | "rate": "rating", 165 | "cast": "cast", 166 | "description": "description", 167 | "genres": "genres", 168 | "poster": "poster", 169 | "backdrop": "backdrop", 170 | "runtime": "runtime", 171 | "logo": "logo" 172 | } 173 | 174 | for meta in (meta_primary, meta_secondary): 175 | if not meta: 176 | continue 177 | for api_key, db_key in api_map.items(): 178 | new_val = meta.get(api_key) 179 | if new_val is None: 180 | continue 181 | 182 | if db_key == "rating": 183 | is_empty = (not current.get(db_key)) or current.get(db_key) == 0 184 | else: 185 | is_empty = not current.get(db_key) 186 | # # If want to add missing data 187 | # if is_empty and new_val: 188 | # update_query[db_key] = new_val 189 | # current[db_key] = new_val 190 | # # If want to add complete data 191 | if new_val is not None: 192 | update_query[db_key] = new_val 193 | current[db_key] = new_val 194 | 195 | 196 | if update_query: 197 | filter_q = {"_id": doc_id} if doc_id else {"imdb_id": imdb_id} 198 | try: 199 | await collection.update_one(filter_q, {"$set": update_query}) 200 | except Exception as e: 201 | LOGGER.exception(f"DB update failed for movie {title}: {e}") 202 | 203 | DONE += 1 204 | 205 | now = time.time() 206 | if now - last_progress_edit > PROGRESS_INTERVAL: 207 | last_progress_edit = now 208 | try: 209 | await status.edit_text( 210 | f"⏳ Fixing metadata...\n{progress_bar(DONE, TOTAL)}\n⏱ Elapsed: {format_eta(now - start_time)}" 211 | ) 212 | except Exception: 213 | pass 214 | 215 | except Exception as e: 216 | LOGGER.exception(f"Error updating movie {movie_doc.get('title')}: {e}") 217 | DONE += 1 218 | 219 | async def _safe_update_tv(collection, tv_doc): 220 | nonlocal DONE, last_progress_edit 221 | 222 | if CANCEL_REQUESTED: 223 | return 224 | 225 | try: 226 | doc_id = tv_doc.get("_id") 227 | imdb_id = tv_doc.get("imdb_id") 228 | tmdb_id = tv_doc.get("tmdb_id") 229 | title = tv_doc.get("title") 230 | year = tv_doc.get("release_year") 231 | 232 | meta_primary = None 233 | meta_secondary = None 234 | 235 | if imdb_id: 236 | meta_primary = await cached_fetch_tv(title, 1, 1, year, imdb_id) 237 | fetched_tmdb = meta_primary.get("tmdb_id") if meta_primary else None 238 | if (tmdb_id or fetched_tmdb) and (not all_fields_present(meta_primary)): 239 | meta_secondary = await cached_fetch_tv(title, 1, 1, year, (tmdb_id or fetched_tmdb)) 240 | elif tmdb_id: 241 | meta_primary = await cached_fetch_tv(title, 1, 1, year, tmdb_id) 242 | fetched_imdb = meta_primary.get("imdb_id") if meta_primary else None 243 | if fetched_imdb and (not all_fields_present(meta_primary)): 244 | meta_secondary = await cached_fetch_tv(title, 1, 1, year, fetched_imdb) 245 | else: 246 | meta_primary = await cached_fetch_tv(title, 1, 1, year, None) 247 | if meta_primary: 248 | fetched_imdb = meta_primary.get("imdb_id") 249 | fetched_tmdb = meta_primary.get("tmdb_id") 250 | if fetched_imdb and (not all_fields_present(meta_primary)): 251 | meta_secondary = await cached_fetch_tv(title, 1, 1, year, fetched_imdb) 252 | elif fetched_tmdb and (not all_fields_present(meta_primary)): 253 | meta_secondary = await cached_fetch_tv(title, 1, 1, year, fetched_tmdb) 254 | 255 | update_query = {} 256 | current = dict(tv_doc) 257 | api_map = { 258 | "imdb_id": "imdb_id", 259 | "tmdb_id": "tmdb_id", 260 | "rate": "rating", 261 | "cast": "cast", 262 | "description": "description", 263 | "genres": "genres", 264 | "poster": "poster", 265 | "backdrop": "backdrop", 266 | "runtime": "runtime", 267 | "logo": "logo" 268 | } 269 | 270 | for meta in (meta_primary, meta_secondary): 271 | if not meta: 272 | continue 273 | for api_key, db_key in api_map.items(): 274 | new_val = meta.get(api_key) 275 | if new_val is None: 276 | continue 277 | if db_key == "rating": 278 | is_empty = (not current.get(db_key)) or current.get(db_key) == 0 279 | else: 280 | is_empty = not current.get(db_key) 281 | # # If want to add missing data 282 | # if is_empty and new_val: 283 | # update_query[db_key] = new_val 284 | # current[db_key] = new_val 285 | # # If want to add complete data 286 | if new_val is not None: 287 | update_query[db_key] = new_val 288 | current[db_key] = new_val 289 | 290 | 291 | if update_query: 292 | filter_q = {"_id": doc_id} if doc_id else {"imdb_id": imdb_id} 293 | try: 294 | await collection.update_one(filter_q, {"$set": update_query}) 295 | except Exception as e: 296 | LOGGER.exception(f"DB update failed for TV {title}: {e}") 297 | 298 | final_imdb = current.get("imdb_id") 299 | if not final_imdb: 300 | DONE += 1 301 | return 302 | 303 | ep_tasks = [] 304 | for season in tv_doc.get("seasons", []): 305 | s_num = season.get("season_number") 306 | for ep in season.get("episodes", []): 307 | e_num = ep.get("episode_number") 308 | 309 | # skip if episode appears complete 310 | if ep.get("overview") and ep.get("released") and ep.get("episode_backdrop"): 311 | continue 312 | 313 | async def ep_task(sn=s_num, en=e_num): 314 | try: 315 | meta = await cached_fetch_tv(title, sn, en, year, final_imdb) 316 | if not meta: 317 | return 318 | 319 | ep_update = {} 320 | if meta.get("episode_overview"): 321 | ep_update["seasons.$[s].episodes.$[e].overview"] = meta["episode_overview"] 322 | if meta.get("episode_released"): 323 | ep_update["seasons.$[s].episodes.$[e].released"] = meta["episode_released"] 324 | if meta.get("episode_backdrop"): 325 | ep_update["seasons.$[s].episodes.$[e].episode_backdrop"] = meta["episode_backdrop"] 326 | 327 | if ep_update: 328 | filt = {"_id": doc_id} if doc_id else {"imdb_id": final_imdb} 329 | await collection.update_one( 330 | filt, 331 | {"$set": ep_update}, 332 | array_filters=[ 333 | {"s.season_number": sn}, 334 | {"e.episode_number": en} 335 | ] 336 | ) 337 | except Exception as e: 338 | LOGGER.exception(f"Error updating episode {title} S{sn}E{en}: {e}") 339 | 340 | ep_tasks.append(ep_task()) 341 | 342 | for i in range(0, len(ep_tasks), TASK_BATCH): 343 | if CANCEL_REQUESTED: 344 | break 345 | batch = ep_tasks[i:i+TASK_BATCH] 346 | running = [asyncio.create_task(t) for t in batch] 347 | await asyncio.gather(*running, return_exceptions=True) 348 | 349 | DONE += 1 350 | 351 | now = time.time() 352 | if now - last_progress_edit > PROGRESS_INTERVAL: 353 | last_progress_edit = now 354 | try: 355 | await status.edit_text( 356 | f"⏳ Fixing metadata...\n{progress_bar(DONE, TOTAL)}\n⏱ Elapsed: {format_eta(now - start_time)}" 357 | ) 358 | except Exception: 359 | pass 360 | 361 | except Exception as e: 362 | LOGGER.exception(f"Error updating TV show {tv_doc.get('title')}: {e}") 363 | DONE += 1 364 | 365 | def all_fields_present(meta: dict) -> bool: 366 | if not meta: 367 | return False 368 | 369 | if not (meta.get("poster") or meta.get("backdrop")): 370 | return False 371 | 372 | has_desc = meta.get("description") or meta.get("genres") or meta.get("cast") 373 | if not has_desc: 374 | return False 375 | 376 | if meta.get("rate") in [0, None]: 377 | return False 378 | 379 | if meta.get("runtime") in [0, None]: 380 | return False 381 | 382 | return True 383 | 384 | 385 | 386 | async def update_movies(): 387 | tasks = [] 388 | for i in range(1, db.current_db_index + 1): 389 | if CANCEL_REQUESTED: 390 | break 391 | collection = db.dbs[f"storage_{i}"]["movie"] 392 | cursor = collection.find({}) 393 | async for movie in cursor: 394 | if CANCEL_REQUESTED: 395 | break 396 | tasks.append(_safe_update_movie(collection, movie)) 397 | if len(tasks) >= TASK_BATCH: 398 | await asyncio.gather(*tasks, return_exceptions=True) 399 | tasks = [] 400 | if tasks: 401 | await asyncio.gather(*tasks, return_exceptions=True) 402 | 403 | async def update_tv_shows(): 404 | tasks = [] 405 | for i in range(1, db.current_db_index + 1): 406 | if CANCEL_REQUESTED: 407 | break 408 | collection = db.dbs[f"storage_{i}"]["tv"] 409 | cursor = collection.find({}) 410 | async for tv in cursor: 411 | if CANCEL_REQUESTED: 412 | break 413 | tasks.append(_safe_update_tv(collection, tv)) 414 | if len(tasks) >= TASK_BATCH: 415 | await asyncio.gather(*tasks, return_exceptions=True) 416 | tasks = [] 417 | if tasks: 418 | await asyncio.gather(*tasks, return_exceptions=True) 419 | 420 | try: 421 | await asyncio.gather(update_movies(), update_tv_shows()) 422 | except Exception as e: 423 | LOGGER.exception(f"Error in fix_metadata run: {e}") 424 | 425 | if CANCEL_REQUESTED: 426 | try: 427 | await status.edit_text("❌ Metadata fixing cancelled by user.") 428 | except Exception: 429 | pass 430 | return 431 | 432 | elapsed = time.time() - start_time 433 | try: 434 | await status.edit_text( 435 | f"🎉 **Metadata Fix Completed!**\n" 436 | f"{progress_bar(DONE, TOTAL)}\n" 437 | f"⏱ Time Taken: {format_eta(elapsed)}" 438 | ) 439 | except Exception: 440 | pass 441 | -------------------------------------------------------------------------------- /Backend/fastapi/templates/media_management.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Media Management - Telegram Stremio{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 8 |
9 |
10 |

Media Management

11 |

Manage your movie and TV show collections

12 |
13 | 14 | 24 |
25 | 26 | 27 |
28 |
29 |
30 | 32 |
33 |
34 | 38 | 42 |
43 |
44 |
45 | 46 | 47 | 51 | 52 | 53 | 68 | 69 | 70 |
71 | 72 |
73 | 74 | 75 | 94 | 95 | 96 | 99 | 100 | 101 | 107 |
108 | 109 | 428 | 429 | 476 | {% endblock %} 477 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | Logo 4 |

5 | 6 | 7 |

8 | A powerful, self-hosted Telegram Stremio Media Server built with FastAPI, MongoDB, and PyroFork — seamlessly integrated with Stremio for automated media streaming and discovery. 9 |

10 | 11 |

12 | UV Package Manager 13 | Python 14 | FastAPI 15 | MongoDB 16 | PyroFork 17 | Stremio 18 | Docker 19 |

20 | 21 | --- 22 | 23 | ## 🧭 Quick Navigation 24 | 25 | - [🚀 Introduction](#-introduction) 26 | - [✨ Key Features](#-key-features) 27 | - [⚙️ How It Works](#️-how-it-works) 28 | - [Overview](#overview) 29 | - [Upload Guidelines](#upload-guidelines) 30 | - [Quality Replacement](#-quality-replacement-logic) 31 | - [Updating CAMRip](#-updating-camrip-or-low-quality-files) 32 | - [Behind The Scenes](#behind-the-scenes) 33 | - [🤖 Bot Commands](#-bot-commands) 34 | - [Command List](#command-list) 35 | - [`/set` Command Usage](#set-command-usage) 36 | - [🔧 Configuration Guide](#-configuration-guide) 37 | - [🧩 Startup Config](#-startup-config) 38 | - [🗄️ Storage](#️-storage) 39 | - [🎬 API](#-api) 40 | - [🌐 Server](#-server) 41 | - [🔄 Update Settings](#-update-settings) 42 | - [🔐 Admin Panel](#-admin-panel) 43 | - [🧰 Additional CDN Bots (Multi-Token System)](#-additional-cdn-bots-multi-token-system) 44 | - [🚀 Deployment Guide](#-deployment-guide) 45 | - [✅ Recommended Prerequisites](#-recommended-prerequisites) 46 | - [🐙 Heroku Guide](#-heroku-guide) 47 | - [🐳 VPS Guide (Recommended)](#-vps-guide) 48 | - [📺 Setting up Stremio](#-setting-up-stremio) 49 | - [🌐 Add the Addon](#-step-3-add-the-addon) 50 | - [⚙️ Optional: Remove Cinemeta](#️-optional-remove-cinemeta) 51 | - [🏅 Contributor](#-contributor) 52 | 53 | 54 | # 🚀 Introduction 55 | 56 | This project is a **next-generation Telegram Stremio Media Server** that allows you to **stream your Telegram files directly through Stremio**, without any third-party dependencies or file expiration issues. It’s designed for **speed, scalability, and reliability**, making it ideal for both personal and community-based media hosting. 57 | 58 | 59 | ## ✨ Key Features 60 | 61 | - ⚙️ **Multiple MongoDB Support** 62 | - 📡 **Multiple Channel Support** 63 | - ⚡ **Fast Streaming Experience** 64 | - 🔑 **Multi Token Load Balancer** 65 | - 🎬 **IMDB and TMDB Metadata Integration** 66 | - ♾️ **No File Expiration** 67 | - 🧠 **Admin Panel Support** 68 | 69 | 70 | ## ⚙️ How It Works 71 | 72 | This project acts as a **bridge between Telegram storage and Stremio streaming**, connecting **Telegram**, **FastAPI**, and **Stremio** to enable seamless movie and TV show streaming directly from Telegram files. 73 | 74 | ### Overview 75 | 76 | When you **forward Telegram files** (movies or TV episodes) to your **AUTH CHANNEL**, the bot automatically: 77 | 78 | 1. 🗃️ **Stores** the `message_id` and `chat_id` in the database. 79 | 2. 🧠 **Processes** file captions to extract key metadata (title, year, quality, etc.). 80 | 3. 🌐 **Generates a streaming URL** through the **PyroFork** module — routed by **FastAPI**. 81 | 4. 🎞️ **Provides Stremio Addon APIs**: 82 | - `/catalog` → Lists available media 83 | - `/meta` → Shows detailed information for each item 84 | - `/stream` → Streams the file directly via Telegram 85 | 86 | ### Upload Guidelines 87 | 88 | To ensure proper metadata extraction and seamless integration with **Stremio**, all uploaded Telegram media files **must include specific details** in their captions. 89 | 90 | #### 🎥 For Movies 91 | 92 | **Example Caption:** 93 | 94 | ``` 95 | Ghosted 2023 720p 10bit WEBRip [Org APTV Hindi AAC 2.0CH + English 6CH] x265 HEVC Msub ~ PSA.mkv 96 | ``` 97 | 98 | **Required Fields:** 99 | 100 | - 🎞️ **Name** – Movie title (e.g., _Ghosted_) 101 | - 📅 **Year** – Release year (e.g., _2023_) 102 | - 📺 **Quality** – Resolution or quality (e.g., _720p_, _1080p_, _2160p_) 103 | 104 | ✅ **Optional:** Include codec, audio format, or source (e.g., `WEBRip`, `x265`, `Dual Audio`). 105 | 106 | #### 📺 For TV Shows 107 | 108 | **Example Caption:** 109 | 110 | ``` 111 | Harikatha.Sambhavami.Yuge.Yuge.S01E04.Dark.Hours.1080p.WEB-DL.DUAL.DDP5.1.Atmos.H.264-Spidey.mkv 112 | ```` 113 | 114 | **Required Fields:** 115 | 116 | - 🎞️ **Name** – TV show title (e.g., _Harikatha Sambhavami Yuge Yuge_) 117 | - 📆 **Season Number** – Use `S` followed by two digits (e.g., `S01`) 118 | - 🎬 **Episode Number** – Use `E` followed by two digits (e.g., `E04`) 119 | - 📺 **Quality** – Resolution or quality (e.g., _1080p_, _720p_) 120 | 121 | ✅ **Optional:** Include episode title, codec, or audio details (e.g., `WEB-DL`, `DDP5.1`, `Dual Audio`). 122 | 123 | ### 🔁 Quality Replacement Logic 124 | 125 | When you upload multiple files with the **same quality label** (like `720p` or `1080p`), 126 | the **latest file automatically replaces the old one**. 127 | 128 | > Example: 129 | > If you already uploaded `Ghosted 2023 720p` and then upload another `720p` version, 130 | > the bot **replaces the old file** to keep your catalog clean and organized. 131 | 132 | This helps avoid duplicate entries in Stremio and ensures only the most recent file is used. 133 | 134 | --- 135 | 136 | ### 🆙 Updating CAMRip or Low-Quality Files 137 | 138 | If you initially uploaded a **CAMRip or low-quality version**, you can easily replace it with a better one: 139 | 140 | 1. Forward the **new, higher-quality file** (e.g., `1080p`, `WEB-DL`) to your **AUTH CHANNEL**. 141 | 2. The bot will **automatically detect and replace** the old CAMRip file in the database. 142 | 3. The Stremio addon will then **update automatically**, showing the new stream source. 143 | 144 | ✅ No manual deletion or command is needed — forwarding the updated file is enough! 145 | 146 | --- 147 | 148 | 149 | ### Behind The Scenes 150 | 151 | Here's how each component interacts: 152 | 153 | | Component | Role | 154 | | :--- | :--- | 155 | | **Telegram Bot** | Handles uploads, forwards, and file tracking. | 156 | | **MongoDB** | Stores message IDs, chat IDs, and metadata. | 157 | | **PyroFork** | Generates Telegram-based streaming URLs. | 158 | | **FastAPI** | Hosts REST endpoints for streaming, catalog, and metadata. | 159 | | **Stremio Addon** | Consumes FastAPI endpoints for catalog display and playback. | 160 | 161 | 📦 **Flow Summary:** 162 | 163 | ``` 164 | Telegram ➜ MongoDB ➜ FastAPI ➜ Stremio ➜ User Stream 165 | ``` 166 | 167 | 168 | 169 | # 🤖 Bot Commands 170 | 171 | Below is the list of available bot commands and their usage within the Telegram bot. 172 | 173 | ### Command List 174 | 175 | | Command | Description | 176 | | :--- | :--- | 177 | | **`/start`** | Returns your **Addon URL** for direct installation in **Stremio**. | 178 | | **`/log`** | Sends the latest **log file** for debugging or monitoring. | 179 | | **`/set`** | Used for **manual uploads** by linking IMDB URLs. | 180 | | **`/restart`** | Restarts the bot and pulls any **latest updates** from the upstream repository. | 181 | 182 | ### `/set` Command Usage 183 | 184 | The `/set` command is used to manually upload a specific Movie or TV show to your channel, linking it to its IMDB metadata. 185 | 186 | **Command:** 187 | 188 | ``` 189 | /set 190 | ``` 191 | 192 | **Example:** 193 | 194 | ``` 195 | /set https://m.imdb.com/title/tt665723 196 | ``` 197 | 198 | **Steps:** 199 | 200 | 1. Send the `/set` command followed by the **IMDB URL** of the movie or show you want to upload. 201 | 2. **Forward the related movie or TV show files** to your channel. 202 | 3. Once all files are uploaded, **clear the default IMDB link** by simply sending the `/set` command without any URL. 203 | 204 | 💡 **Tip:** Use `/log` if you encounter any upload or parsing issues. 205 | 206 | 207 | # 🔧 Configuration Guide 208 | 209 | All environment variables for this project are defined in the `config.env` file. A detailed explanation of each parameter is provided below. 210 | 211 | ### 🧩 Startup Config 212 | 213 | | Variable | Description | 214 | | :--- | :--- | 215 | | **`API_ID`** | Your Telegram **API ID** from [my.telegram.org](https://my.telegram.org). Used for authenticating your Telegram session. | 216 | | **`API_HASH`** | Your Telegram **API Hash** from [my.telegram.org](https://my.telegram.org). | 217 | | **`BOT_TOKEN`** | The main bot’s **access token** from [@BotFather](https://t.me/BotFather). Handles user requests and media fetching. | 218 | | **`HELPER_BOT_TOKEN`** | **Secondary bot token** used to assist the main bot with tasks like deleting, editing, or managing. | 219 | | **`OWNER_ID`** | Your **Telegram user ID**. This ID has full administrative access. | 220 | | **`REPLACE_MODE`** | When `true`, new files replace existing files of the same quality. When `false`, multiple files of the same quality are allowed. | 221 | 222 | ### 🗄️ Storage 223 | 224 | | Variable | Description | 225 | | :--- | :--- | 226 | | **`AUTH_CHANNEL`** | One or more **Telegram channel IDs** (comma-separated) where the bot is authorized to fetch or stream content. *Example: `-1001234567890, -1009876543210`*. | 227 | | **`DATABASE`** | MongoDB Atlas connection URI(s). You **must provide at least two databases**, separated by commas (`,`) for load balancing and redundancy.
Example:
`mongodb+srv://user:pass@cluster0.mongodb.net/db1, mongodb+srv://user:pass@cluster1.mongodb.net/db2` | 228 | 229 | > 💡 **Tip:** Create your MongoDB Atlas cluster [here](https://www.mongodb.com/cloud/atlas). 230 | 231 | ### 🎬 API 232 | 233 | | Variable | Description | 234 | | :--- | :--- | 235 | | **`TMDB_API`** | Your **TMDB API key** from [themoviedb.org](https://www.themoviedb.org/settings/api). Used to fetch movie and TV metadata. | 236 | 237 | ### 🌐 Server 238 | 239 | | Variable | Description | 240 | | :--- | :--- | 241 | | **`BASE_URL`** | The Domain or Heroku app URL (e.g. `https://your-domain.com`). Crucial for Stremio addon setup. | 242 | | **`PORT`** | The port number on which your FastAPI server will run. *Default: `8000`*. | 243 | 244 | ### 🔄 Update Settings 245 | 246 | | Variable | Description | 247 | | :--- | :--- | 248 | | **`UPSTREAM_REPO`** | GitHub repository URL for automatic updates. | 249 | | **`UPSTREAM_BRANCH`** | The branch name to track in your upstream repo. *Default: `master`*. | 250 | 251 | ### 🔐 Admin Panel 252 | 253 | | Variable | Description | 254 | | :--- | :--- | 255 | | **`ADMIN_USERNAME`** | Username for logging into the Admin Panel. | 256 | | **`ADMIN_PASSWORD`** | Password for Admin Panel access.| 257 | **⚠️ Change from default values for security.** 258 | 259 | ### 🧰 Additional CDN Bots (Multi-Token System) 260 | 261 | | Variable | Description | 262 | | :--- | :--- | 263 | | **`MULTI_TOKEN1`**, **`MULTI_TOKEN2`**, ... | Extra bot tokens used to distribute traffic and prevent Telegram rate-limiting. Add each bot as an **Admin** in your `AUTH_CHANNEL`(s). | 264 | 265 | #### About `MULTI_TOKEN` 266 | 267 | If your bot handles a high number of downloads/requests at a time, Telegram may limit your main bot. 268 | To avoid this, you can use **MULTI_TOKEN** system: 269 | 270 | - Create multiple bots using [@BotFather](https://t.me/BotFather). 271 | - Add each bot as **Admin** in your `AUTH_CHANNEL`(s). 272 | - Add the tokens in your `config.env` as `MULTI_TOKEN1`, `MULTI_TOKEN2`, `MULTI_TOKEN3`, and so on. 273 | - The system will automatically distribute the load among all these bots! 274 | 275 | 276 | # 🚀 Deployment Guide 277 | 278 | This guide will help you deploy your **Telegram Stremio Media Server** using either Heroku or a VPS with Docker. 279 | 280 | ## ✅ Recommended Prerequisites 281 | 282 | **Supported Servers:** 283 | 284 | - 🟣 **Heroku** 285 | - 🟢 **VPS** 286 | 287 | Before you begin, ensure you have: 288 | 289 | 1. ✅ A **VPS** with a public IP (e.g., Ubuntu on DigitalOcean, AWS, Vultr, etc.) 290 | 2. ✅ A **Domain name** 291 | 292 | 293 | ## 🐙 Heroku Guide 294 | 295 | Follow the instructions provided in the Google Colab Tool to deploy on Heroku. 296 | 297 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/weebzone/Colab-Tools/blob/main/telegram%20stremio.ipynb) 298 | 299 | 300 | ## 🐳 VPS Guide 301 | 302 | This section explains how to deploy your **Telegram Stremio Media Server** on a VPS using **Docker Compose (recommended)** or **Docker**. 303 | 304 | 305 | ### 1️⃣ Step 1: Clone & Configure the Project 306 | 307 | ```bash 308 | git clone https://github.com/weebzone/Telegram-Stremio 309 | cd Telegram-Stremio 310 | mv sample_config.env config.env 311 | nano config.env 312 | ``` 313 | 314 | * Fill in all required variables in `config.env`. 315 | * Press `Ctrl + O`, then `Enter`, then `Ctrl + X` to save and exit. 316 | 317 | ## ⚙️ Step 2: Choose Your Deployment Method 318 | 319 | You can deploy the server using either **Docker Compose (recommended)** or **plain Docker**. 320 | 321 | 322 | 323 | ### 🟢 **Option 1: Deploy with Docker Compose (Recommended)** 324 | 325 | Docker Compose provides an easier and more maintainable setup, environment mounting, and restart policies. 326 | 327 | #### 🚀 Start the Container 328 | 329 | ```bash 330 | docker compose up -d 331 | ``` 332 | 333 | Your server will now be running at: 334 | ➡️ `http://:8000` 335 | 336 | --- 337 | 338 | #### 🛠️ Update `config.env` While Running 339 | 340 | If you need to modify environment values (like `BASE_URL`, `AUTH_CHANNEL`, etc.): 341 | 342 | 1. **Edit the file:** 343 | 344 | ```bash 345 | nano config.env 346 | ``` 347 | 2. **Save your changes:** (`Ctrl + O`, `Enter`, `Ctrl + X`) 348 | 3. **Restart the container to apply updates:** 349 | 350 | ```bash 351 | docker compose restart 352 | ``` 353 | 354 | ⚡ Since the config file is mounted, you **don’t need to rebuild** the image — changes apply automatically on restart. 355 | 356 | 357 | 358 | ### 🔵 **Option 2: Deploy with Docker (Manual Method)** 359 | 360 | If you prefer not to use Docker Compose, you can manually build and run the container. 361 | 362 | #### 🧩 Build the Image 363 | 364 | ```bash 365 | docker build -t telegram-stremio . 366 | ``` 367 | 368 | #### 🚀 Run the Container 369 | 370 | ```bash 371 | docker run -d -p 8000:8000 telegram-stremio 372 | ``` 373 | 374 | Your server should now be running at: 375 | ➡️ `http://:8000` 376 | 377 | 378 | 379 | ### 🌐 Step 3: Add Domain (Required) 380 | 381 | #### 🅰️ Set Up DNS Records 382 | 383 | Go to your domain registrar and add an **A record** pointing to your VPS IP: 384 | 385 | | Type | Name | Value | 386 | | ---- | ---- | ----------------- | 387 | | A | @ | `195.xxx.xxx.xxx` | 388 | 389 | 390 | #### 🧱 Install Caddy (for HTTPS + Reverse Proxy) 391 | 392 | ```bash 393 | sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl 394 | curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg 395 | curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list 396 | chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg 397 | chmod o+r /etc/apt/sources.list.d/caddy-stable.list 398 | sudo apt update 399 | sudo apt install caddy 400 | ``` 401 | 402 | #### ⚙️ Configure Caddy 403 | 404 | 1. **Edit the Caddyfile:** 405 | 406 | ```bash 407 | sudo nano /etc/caddy/Caddyfile 408 | ``` 409 | 410 | 2. **Replace contents with:** 411 | 412 | ```caddy 413 | your-domain.com { 414 | reverse_proxy localhost:8000 415 | } 416 | ``` 417 | 418 | * Replace `your-domain.com` with your actual domain name. 419 | * Adjust the port if you changed it in `config.env`. 420 | 421 | 3. **Save and reload Caddy:** 422 | 423 | ```bash 424 | sudo systemctl reload caddy 425 | ``` 426 | 427 | 428 | ✅ Your API will now be available securely at: 429 | ➡️ `https://your-domain.com` 430 | 431 | 432 | # 📺 Setting up Stremio 433 | 434 | Follow these steps to connect your deployed addon to the **Stremio** app. 435 | 436 | ### 📥 Step 1: Download Stremio 437 | 438 | Download Stremio for your device: 439 | 👉 [https://www.stremio.com/downloads](https://www.stremio.com/downloads) 440 | 441 | ### 👤 Step 2: Sign In 442 | 443 | - Create or log in to your **Stremio account**. 444 | 445 | ### 🌐 Step 3: Add the Addon 446 | 447 | 1. Open the **Stremio App**. 448 | 2. Go to the **Addon Section** (usually represented by a puzzle piece icon 🧩). 449 | 3. In the search bar, paste the appropriate addon URL: 450 | 451 | | Deployment Method | Addon URL | 452 | | :--- | :--- | 453 | | **Heroku** | `https://.herokuapp.com/stremio/manifest.json` | 454 | | **Custom Domain** | `https:///stremio/manifest.json` | 455 | 456 | 457 | ## ⚙️ Optional: Remove Cinemeta 458 | 459 | If you want to use **only** your **Telegram Stremio Media Server addon** for metadata and streaming, follow this guide to remove the default `Cinemeta` addon. 460 | 461 | ### 1️⃣ Step 1: Uninstall Other Addons 462 | 463 | 1. Go to the **Addon Section** in the Stremio App. 464 | 2. **Uninstall all addons** except your Telegram Stremio Media Server. 465 | 3. Attempt to remove **Cinemeta**. If Stremio prevents it, proceed to Step 2. 466 | 467 | ### 2️⃣ Step 2: Remove “Cinemeta” Protection 468 | 469 | 1. Log in to your **Stremio account** using **Chrome or Chromium-based browser** : 470 | 👉 [https://web.stremio.com/](https://web.stremio.com/) 471 | 2. Once logged in, open your **browser console** (`Ctrl + Shift + J` on Windows/Linux or `Cmd + Option + J` on macOS). 472 | 3. Copy and paste the code below into the console and press **Enter**: 473 | 474 | 475 | 476 | ```js 477 | (function() { 478 | 479 | const token = JSON.parse(localStorage.getItem("profile")).auth.key; 480 | 481 | const requestData = { 482 | type: "AddonCollectionGet", 483 | authKey: token, 484 | update: true 485 | }; 486 | 487 | fetch('https://api.strem.io/api/addonCollectionGet', { 488 | method: 'POST', 489 | body: JSON.stringify(requestData) 490 | }) 491 | .then(response => response.json()) 492 | .then(data => { 493 | 494 | if (data && data.result) { 495 | 496 | let result = JSON.stringify(data.result).substring(1).replace(/"protected":true/g, '"protected":false').replace('"idPrefixes":["tmdb:"]', '"idPrefixes":["tmdb:","tt"]'); 497 | 498 | const index = result.indexOf("}}],"); 499 | 500 | if (index !== -1) { 501 | result = result.substring(0, index + 3) + "}"; 502 | } 503 | 504 | let addons = '{"type":"AddonCollectionSet","authKey":"' + token + '",' + result; 505 | 506 | fetch('https://api.strem.io/api/addonCollectionSet', { 507 | method: 'POST', 508 | body: addons 509 | }) 510 | .then(response => response.text()) 511 | .then(data => { 512 | console.log('Success:', data); 513 | }) 514 | .catch((error) => { 515 | console.error('Error:', error); 516 | }); 517 | 518 | } else { 519 | console.error('Error:', error); 520 | } 521 | }) 522 | .catch((error) => { 523 | console.error('Erro:', error); 524 | }); 525 | })(); 526 | ``` 527 | 528 | ### 3️⃣ Step 3: Confirm Success 529 | 530 | - Wait until you see this message in the console: 531 | ``` 532 | Success: {"result":{"success":true}} 533 | ``` 534 | - Refresh the page (**F5**). You will now be able to **remove Cinemeta** from your addons list. 535 | 536 | 537 | ## 🏅 **Contributor** 538 | 539 | |||| 540 | |:---:|:---:|:---:| 541 | |[`Karan`](https://github.com/Weebzone)|[`Stremio`](https://github.com/Stremio)|[`ChatGPT`](https://github.com/OPENAI)| 542 | |Author|Stremio SDK|Refactor 543 | 544 | -------------------------------------------------------------------------------- /Backend/helper/metadata.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import traceback 3 | import PTN 4 | import re 5 | from re import compile, IGNORECASE 6 | from Backend.helper.imdb import get_detail, get_season, search_title 7 | from themoviedb import aioTMDb 8 | from Backend.config import Telegram 9 | import Backend 10 | from Backend.logger import LOGGER 11 | from Backend.helper.encrypt import encode_string 12 | 13 | # ----------------- Configuration ----------------- 14 | DELAY = 0 15 | tmdb = aioTMDb(key=Telegram.TMDB_API, language="en-US", region="US") 16 | 17 | # Cache dictionaries (per run) 18 | IMDB_CACHE: dict = {} 19 | TMDB_SEARCH_CACHE: dict = {} 20 | TMDB_DETAILS_CACHE: dict = {} 21 | EPISODE_CACHE: dict = {} 22 | 23 | # Concurrency semaphore for external API calls 24 | API_SEMAPHORE = asyncio.Semaphore(12) 25 | 26 | # ----------------- Helpers ----------------- 27 | def format_tmdb_image(path: str, size="w500") -> str: 28 | if not path: 29 | return "" 30 | return f"https://image.tmdb.org/t/p/{size}{path}" 31 | 32 | def get_tmdb_logo(images) -> str: 33 | if not images: 34 | return "" 35 | logos = getattr(images, "logos", None) 36 | if not logos: 37 | return "" 38 | for logo in logos: 39 | iso_lang = getattr(logo, "iso_639_1", None) 40 | file_path = getattr(logo, "file_path", None) 41 | if iso_lang == "en" and file_path: 42 | return format_tmdb_image(file_path, "w300") 43 | for logo in logos: 44 | file_path = getattr(logo, "file_path", None) 45 | if file_path: 46 | return format_tmdb_image(file_path, "w300") 47 | return "" 48 | 49 | 50 | def format_imdb_images(imdb_id: str) -> dict: 51 | if not imdb_id: 52 | return {"poster": "", "backdrop": "", "logo": ""} 53 | return { 54 | "poster": f"https://images.metahub.space/poster/small/{imdb_id}/img", 55 | "backdrop": f"https://images.metahub.space/background/medium/{imdb_id}/img", 56 | "logo": f"https://images.metahub.space/logo/medium/{imdb_id}/img", 57 | } 58 | 59 | def extract_default_id(url: str) -> str | None: 60 | # IMDb 61 | imdb_match = re.search(r'/title/(tt\d+)', url) 62 | if imdb_match: 63 | return imdb_match.group(1) 64 | 65 | # TMDb movie or TV 66 | tmdb_match = re.search(r'/((movie|tv))/(\d+)', url) 67 | if tmdb_match: 68 | return tmdb_match.group(3) 69 | 70 | return None 71 | 72 | async def safe_imdb_search(title: str, type_: str) -> str | None: 73 | key = f"imdb::{type_}::{title}" 74 | if key in IMDB_CACHE: 75 | return IMDB_CACHE[key] 76 | try: 77 | async with API_SEMAPHORE: 78 | result = await search_title(query=title, type=type_) 79 | imdb_id = result["id"] if result else None 80 | IMDB_CACHE[key] = imdb_id 81 | return imdb_id 82 | except Exception as e: 83 | LOGGER.warning(f"IMDb search failed for '{title}' [{type_}]: {e}") 84 | return None 85 | 86 | async def safe_tmdb_search(title: str, type_: str, year=None): 87 | key = f"tmdb_search::{type_}::{title}::{year}" 88 | if key in TMDB_SEARCH_CACHE: 89 | return TMDB_SEARCH_CACHE[key] 90 | try: 91 | async with API_SEMAPHORE: 92 | if type_ == "movie": 93 | results = await tmdb.search().movies(query=title, year=year) if year else await tmdb.search().movies(query=title) 94 | else: 95 | results = await tmdb.search().tv(query=title) 96 | res = results[0] if results else None 97 | TMDB_SEARCH_CACHE[key] = res 98 | return res 99 | except Exception as e: 100 | LOGGER.error(f"TMDb search failed for '{title}' [{type_}]: {e}") 101 | TMDB_SEARCH_CACHE[key] = None 102 | return None 103 | 104 | async def _tmdb_movie_details(movie_id): 105 | if movie_id in TMDB_DETAILS_CACHE: 106 | return TMDB_DETAILS_CACHE[movie_id] 107 | try: 108 | async with API_SEMAPHORE: 109 | details = await tmdb.movie(movie_id).details( 110 | append_to_response="external_ids,credits" 111 | ) 112 | images = await tmdb.movie(movie_id).images() 113 | details.images = images 114 | 115 | TMDB_DETAILS_CACHE[movie_id] = details 116 | return details 117 | except Exception as e: 118 | LOGGER.warning(f"TMDb movie details fetch failed for id={movie_id}: {e}") 119 | TMDB_DETAILS_CACHE[movie_id] = None 120 | return None 121 | 122 | 123 | async def _tmdb_tv_details(tv_id): 124 | if tv_id in TMDB_DETAILS_CACHE: 125 | return TMDB_DETAILS_CACHE[tv_id] 126 | try: 127 | async with API_SEMAPHORE: 128 | details = await tmdb.tv(tv_id).details( 129 | append_to_response="external_ids,credits" 130 | ) 131 | images = await tmdb.tv(tv_id).images() 132 | details.images = images 133 | TMDB_DETAILS_CACHE[tv_id] = details 134 | return details 135 | except Exception as e: 136 | LOGGER.warning(f"TMDb tv details fetch failed for id={tv_id}: {e}") 137 | TMDB_DETAILS_CACHE[tv_id] = None 138 | return None 139 | 140 | 141 | async def _tmdb_episode_details(tv_id, season, episode): 142 | key = (tv_id, season, episode) 143 | if key in EPISODE_CACHE: 144 | return EPISODE_CACHE[key] 145 | try: 146 | async with API_SEMAPHORE: 147 | details = await tmdb.episode(tv_id, season, episode).details() 148 | EPISODE_CACHE[key] = details 149 | return details 150 | except Exception: 151 | EPISODE_CACHE[key] = None 152 | return None 153 | 154 | # ----------------- Main Metadata ----------------- 155 | async def metadata(filename: str, channel: int, msg_id) -> dict | None: 156 | try: 157 | parsed = PTN.parse(filename) 158 | except Exception as e: 159 | LOGGER.error(f"PTN parsing failed for {filename}: {e}\n{traceback.format_exc()}") 160 | return None 161 | 162 | # Skip combined/invalid files 163 | if "excess" in parsed and any("combined" in item.lower() for item in parsed["excess"]): 164 | LOGGER.info(f"Skipping {filename}: contains 'combined'") 165 | return None 166 | 167 | # Skip split/multipart files 168 | multipart_pattern = compile(r'(?:part|cd|disc|disk)[s._-]*\d+(?=\.\w+$)', IGNORECASE) 169 | if multipart_pattern.search(filename): 170 | LOGGER.info(f"Skipping {filename}: seems to be a split/multipart file") 171 | return None 172 | 173 | title = parsed.get("title") 174 | season = parsed.get("season") 175 | episode = parsed.get("episode") 176 | year = parsed.get("year") 177 | quality = parsed.get("resolution") 178 | if isinstance(season, list) or isinstance(episode, list): 179 | LOGGER.warning(f"Invalid season/episode format for {filename}: {parsed}") 180 | return None 181 | if season and not episode: 182 | LOGGER.warning(f"Missing episode in {filename}: {parsed}") 183 | return None 184 | if not quality: 185 | LOGGER.warning(f"Skipping {filename}: No resolution (parsed={parsed})") 186 | return None 187 | if not title: 188 | LOGGER.info(f"No title parsed from: {filename} (parsed={parsed})") 189 | return None 190 | 191 | 192 | default_id = None 193 | try: 194 | default_id = extract_default_id(Backend.USE_DEFAULT_ID) 195 | except Exception: 196 | pass 197 | if not default_id: 198 | try: 199 | default_id = extract_default_id(filename) 200 | except Exception: 201 | pass 202 | 203 | data = {"chat_id": channel, "msg_id": msg_id} 204 | try: 205 | encoded_string = await encode_string(data) 206 | except Exception: 207 | encoded_string = None 208 | 209 | try: 210 | if season and episode: 211 | LOGGER.info(f"Fetching TV metadata: {title} S{season}E{episode}") 212 | return await fetch_tv_metadata(title, season, episode, encoded_string, year, quality, default_id) 213 | else: 214 | LOGGER.info(f"Fetching Movie metadata: {title} ({year})") 215 | return await fetch_movie_metadata(title, encoded_string, year, quality, default_id) 216 | except Exception as e: 217 | LOGGER.error(f"Error while fetching metadata for {filename}: {e}\n{traceback.format_exc()}") 218 | return None 219 | 220 | # ----------------- TV Metadata ----------------- 221 | async def fetch_tv_metadata(title, season, episode, encoded_string, year=None, quality=None, default_id=None) -> dict | None: 222 | imdb_id = None 223 | tmdb_id = None 224 | imdb_tv = None 225 | imdb_ep = None 226 | use_tmdb = False 227 | 228 | # ------------------------------------------------------- 229 | # 1. Handle default ID (IMDb / TMDb) 230 | # ------------------------------------------------------- 231 | if default_id: 232 | default_id = str(default_id) 233 | if default_id.startswith("tt"): 234 | imdb_id = default_id 235 | use_tmdb = False 236 | elif default_id.isdigit(): 237 | tmdb_id = int(default_id) 238 | use_tmdb = True 239 | 240 | # ------------------------------------------------------- 241 | # 2. If no ID → Try IMDb search first 242 | # ------------------------------------------------------- 243 | if not imdb_id and not tmdb_id: 244 | imdb_id = await safe_imdb_search(title, "tvSeries") 245 | use_tmdb = not bool(imdb_id) 246 | 247 | # ------------------------------------------------------- 248 | # 3. IMDb fetch (series + episode) 249 | # ------------------------------------------------------- 250 | if imdb_id and not use_tmdb: 251 | try: 252 | # ----- series details 253 | if imdb_id in IMDB_CACHE: 254 | imdb_tv = IMDB_CACHE[imdb_id] 255 | else: 256 | async with API_SEMAPHORE: 257 | imdb_tv = await get_detail(imdb_id=imdb_id, media_type="tvSeries") 258 | IMDB_CACHE[imdb_id] = imdb_tv 259 | 260 | # ----- episode details 261 | ep_key = f"{imdb_id}::{season}::{episode}" 262 | if ep_key in EPISODE_CACHE: 263 | imdb_ep = EPISODE_CACHE[ep_key] 264 | else: 265 | async with API_SEMAPHORE: 266 | imdb_ep = await get_season(imdb_id=imdb_id, season_id=season, episode_id=episode) 267 | EPISODE_CACHE[ep_key] = imdb_ep 268 | 269 | except Exception as e: 270 | LOGGER.warning(f"IMDb TV fetch failed [{imdb_id}] → {e}") 271 | imdb_tv = None 272 | imdb_ep = None 273 | use_tmdb = True 274 | 275 | # ------------------------------------------------------- 276 | # 4. Decide if TMDb required 277 | # ------------------------------------------------------- 278 | must_use_tmdb = ( 279 | use_tmdb or 280 | imdb_tv is None or 281 | imdb_tv == {} 282 | ) 283 | 284 | # ======================================================= 285 | # 5. TMDb MODE 286 | # ======================================================= 287 | if must_use_tmdb: 288 | LOGGER.info(f"No valid IMDb TV data for '{title}' → using TMDb") 289 | 290 | # Search TMDb by title 291 | if not tmdb_id: 292 | tmdb_search = await safe_tmdb_search(title, "tv", year) 293 | if not tmdb_search: 294 | LOGGER.warning(f"No TMDb TV result for '{title}'") 295 | return None 296 | tmdb_id = tmdb_search.id 297 | 298 | # Fetch full TV show details 299 | tv = await _tmdb_tv_details(tmdb_id) 300 | if not tv: 301 | LOGGER.warning(f"TMDb TV details failed for id={tmdb_id}") 302 | return None 303 | 304 | # Fetch episode 305 | ep = await _tmdb_episode_details(tmdb_id, season, episode) 306 | 307 | # Cast list 308 | credits = getattr(tv, "credits", None) or {} 309 | cast_arr = getattr(credits, "cast", []) or [] 310 | cast = [ 311 | getattr(c, "name", None) or getattr(c, "original_name", None) 312 | for c in cast_arr 313 | ] 314 | 315 | # Runtime (prefer episode → series → empty) 316 | ep_runtime = getattr(ep, "runtime", None) if ep else None 317 | series_runtime = ( 318 | tv.episode_run_time[0] if getattr(tv, "episode_run_time", None) else None 319 | ) 320 | runtime_val = ep_runtime or series_runtime 321 | runtime = f"{runtime_val} min" if runtime_val else "" 322 | 323 | return { 324 | "tmdb_id": tv.id, 325 | "imdb_id": getattr(getattr(tv, "external_ids", None), "imdb_id", None), 326 | "title": tv.name, 327 | "year": getattr(tv.first_air_date, "year", 0) if getattr(tv, "first_air_date", None) else 0, 328 | "rate": getattr(tv, "vote_average", 0) or 0, 329 | "description": tv.overview or "", 330 | "poster": format_tmdb_image(tv.poster_path), 331 | "backdrop": format_tmdb_image(tv.backdrop_path, "original"), 332 | "logo": get_tmdb_logo(getattr(tv, "images", None)), 333 | "genres": [g.name for g in (tv.genres or [])], 334 | "media_type": "tv", 335 | "cast": cast, 336 | "runtime": str(runtime), 337 | 338 | "season_number": season, 339 | "episode_number": episode, 340 | "episode_title": getattr(ep, "name", f"S{season}E{episode}") if ep else f"S{season}E{episode}", 341 | "episode_backdrop": format_tmdb_image(getattr(ep, "still_path", None), "original") if ep else "", 342 | "episode_overview": getattr(ep, "overview", "") if ep else "", 343 | "episode_released": ( 344 | ep.air_date.strftime("%Y-%m-%dT05:00:00.000Z") 345 | if getattr(ep, "air_date", None) 346 | else "" 347 | ), 348 | 349 | "quality": quality, 350 | "encoded_string": encoded_string, 351 | } 352 | 353 | # ======================================================= 354 | # 6. IMDb MODE 355 | # ======================================================= 356 | imdb = imdb_tv or {} 357 | ep = imdb_ep or {} 358 | 359 | images = format_imdb_images(imdb_id) 360 | 361 | return { 362 | "tmdb_id": imdb.get("moviedb_id") or imdb_id.replace("tt", ""), 363 | "imdb_id": imdb_id, 364 | "title": imdb.get("title", title), 365 | "year": imdb.get("releaseDetailed", {}).get("year", 0), 366 | "rate": imdb.get("rating", {}).get("star", 0), 367 | "description": imdb.get("plot", ""), 368 | "poster": images["poster"], 369 | "backdrop": images["backdrop"], 370 | "logo": images["logo"], 371 | "cast": imdb.get("cast", []), 372 | "runtime": str(imdb.get("runtime") or ""), 373 | "genres": imdb.get("genre", []), 374 | "media_type": "tv", 375 | 376 | "season_number": season, 377 | "episode_number": episode, 378 | "episode_title": ep.get("title", f"S{season}E{episode}"), 379 | "episode_backdrop": ep.get("image", ""), 380 | "episode_overview": ep.get("plot", ""), 381 | "episode_released": str(ep.get("released", "")), 382 | 383 | "quality": quality, 384 | "encoded_string": encoded_string, 385 | } 386 | 387 | 388 | # ----------------- Movie Metadata ----------------- 389 | async def fetch_movie_metadata(title, encoded_string, year=None, quality=None, default_id=None) -> dict | None: 390 | imdb_id = None 391 | tmdb_id = None 392 | imdb_details = None 393 | use_tmdb = False 394 | 395 | # ------------------------------------------------------- 396 | # 1. PROCESS DEFAULT ID (tt = IMDb, digits = TMDb) 397 | # ------------------------------------------------------- 398 | if default_id: 399 | default_id = str(default_id).strip() 400 | 401 | if default_id.startswith("tt"): 402 | imdb_id = default_id 403 | use_tmdb = False # force IMDb 404 | elif default_id.isdigit(): 405 | tmdb_id = int(default_id) 406 | use_tmdb = True # force TMDb 407 | 408 | # ------------------------------------------------------- 409 | # 2. IF NO DEFAULT ID → SEARCH IMDb FIRST 410 | # ------------------------------------------------------- 411 | if not imdb_id and not tmdb_id: 412 | imdb_id = await safe_imdb_search( 413 | f"{title} {year}" if year else title, 414 | "movie" 415 | ) 416 | use_tmdb = not bool(imdb_id) 417 | 418 | # ------------------------------------------------------- 419 | # 3. FETCH IMDb DETAILS (only if imdb_id exists) 420 | # ------------------------------------------------------- 421 | if imdb_id and not use_tmdb: 422 | try: 423 | if imdb_id in IMDB_CACHE: 424 | imdb_details = IMDB_CACHE[imdb_id] 425 | else: 426 | async with API_SEMAPHORE: 427 | imdb_details = await get_detail( 428 | imdb_id=imdb_id, 429 | media_type="movie" 430 | ) 431 | 432 | IMDB_CACHE[imdb_id] = imdb_details 433 | 434 | except Exception as e: 435 | LOGGER.warning(f"IMDb movie fetch failed [{title}] → {e}") 436 | imdb_details = None 437 | use_tmdb = True 438 | 439 | # ------------------------------------------------------- 440 | # 4. DECIDE FINAL DATA SOURCE 441 | # ------------------------------------------------------- 442 | must_use_tmdb = ( 443 | use_tmdb or 444 | imdb_details is None or 445 | imdb_details == {} 446 | ) 447 | 448 | # ======================================================= 449 | # 5. TMDb MODE 450 | # ======================================================= 451 | if must_use_tmdb: 452 | LOGGER.info(f"No valid IMDb data for '{title}' → using TMDb") 453 | 454 | # TMDb search if id unknown 455 | if not tmdb_id: 456 | tmdb_result = await safe_tmdb_search(title, "movie", year) 457 | if not tmdb_result: 458 | LOGGER.warning(f"No TMDb movie found for '{title}'") 459 | return None 460 | tmdb_id = tmdb_result.id 461 | 462 | # Fetch TMDb details 463 | movie = await _tmdb_movie_details(tmdb_id) 464 | if not movie: 465 | LOGGER.warning(f"TMDb details failed for {tmdb_id}") 466 | return None 467 | 468 | # Cast extraction 469 | credits = getattr(movie, "credits", None) or {} 470 | cast_arr = getattr(credits, "cast", []) or [] 471 | cast_names = [ 472 | getattr(c, "name", None) or getattr(c, "original_name", None) 473 | for c in cast_arr 474 | ] 475 | 476 | runtime_val = getattr(movie, "runtime", None) 477 | runtime = f"{runtime_val} min" if runtime_val else "" 478 | 479 | return { 480 | "tmdb_id": movie.id, 481 | "imdb_id": getattr(movie.external_ids, "imdb_id", None), 482 | "title": movie.title, 483 | "year": getattr(movie.release_date, "year", 0) if getattr(movie, "release_date", None) else 0, 484 | "rate": getattr(movie, "vote_average", 0) or 0, 485 | "description": movie.overview or "", 486 | "poster": format_tmdb_image(movie.poster_path), 487 | "backdrop": format_tmdb_image(movie.backdrop_path, "original"), 488 | "logo": get_tmdb_logo(getattr(movie, "images", None)), 489 | "cast": cast_names, 490 | "runtime": str(runtime), 491 | "media_type": "movie", 492 | "genres": [g.name for g in (movie.genres or [])], 493 | "quality": quality, 494 | "encoded_string": encoded_string, 495 | } 496 | 497 | # ======================================================= 498 | # 6. IMDb MODE 499 | # ======================================================= 500 | images = format_imdb_images(imdb_id) 501 | imdb = imdb_details or {} 502 | 503 | return { 504 | "tmdb_id": imdb.get("moviedb_id") or imdb_id.replace("tt", ""), 505 | "imdb_id": imdb_id, 506 | "title": imdb.get("title", title), 507 | "year": imdb.get("releaseDetailed", {}).get("year", 0), 508 | "rate": imdb.get("rating", {}).get("star", 0), 509 | "description": imdb.get("plot", ""), 510 | "poster": images["poster"], 511 | "backdrop": images["backdrop"], 512 | "logo": images["logo"], 513 | "cast": imdb.get("cast", []), 514 | "runtime": str(imdb.get("runtime") or ""), 515 | "media_type": "movie", 516 | "genres": imdb.get("genre", []), 517 | "quality": quality, 518 | "encoded_string": encoded_string, 519 | } 520 | --------------------------------------------------------------------------------