Spaces:
Running
Running
Download addon.py from Deep7477474/Stremio: direct link, hf CLI and curl.
- Browser
- Download file 97.7 kB
-
https://huggingface.co/spaces/Deep7477474/Stremio/resolve/main/addon.py
- Command line
-
hf download hf://spaces/Deep7477474/Stremio/addon.py
-
curl -L -o addon.py https://huggingface.co/spaces/Deep7477474/Stremio/resolve/main/addon.py
97.7 kB
| import logging | |
| import asyncio | |
| # Fix Pyrogram event loop crash on Python 3.12/3.14 | |
| try: | |
| asyncio.get_event_loop() | |
| except RuntimeError: | |
| asyncio.set_event_loop(asyncio.new_event_loop()) | |
| import re | |
| import time | |
| import urllib.parse | |
| import markupsafe | |
| from contextlib import asynccontextmanager | |
| from fastapi import FastAPI, Request, HTTPException, Depends, Response | |
| from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| def get_addon_url(request: Request = None) -> str: | |
| if request is not None: | |
| proto = request.headers.get("x-forwarded-proto", request.url.scheme or "https") | |
| host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc)) | |
| if host and "localhost" not in host and "127.0.0.1" not in host: | |
| return f"{proto}://{host}".rstrip("/") | |
| if getattr(Config, "ADDON_URL", None) and "localhost" not in Config.ADDON_URL and "127.0.0.1" not in Config.ADDON_URL: | |
| return Config.ADDON_URL.rstrip("/") | |
| if request is not None: | |
| return str(request.base_url).rstrip("/") | |
| return f"http://localhost:{getattr(Config, 'PORT', 7860)}" | |
| import os | |
| def format_content_disposition(filename: str, disposition: str = "inline") -> str: | |
| safe = filename.replace("\r", " ").replace("\n", " ").replace('"', '').replace("'", '').strip() | |
| if len(safe) > 100: | |
| safe = safe[:97] + "..." | |
| ascii_name = safe.encode("ascii", "ignore").decode("ascii").strip() | |
| ext = os.path.splitext(safe)[1] | |
| if not ascii_name or ascii_name == ext: | |
| ascii_name = f"media{ext if ext else '.mp4'}" | |
| utf8_name = urllib.parse.quote(safe) | |
| return f'{disposition}; filename="{ascii_name}"; filename*=UTF-8\'\'{utf8_name}' | |
| from config import Config | |
| from tg_client import tg_client_manager | |
| from utils import ( | |
| format_size, | |
| normalize_release_name, | |
| matches_episode, | |
| get_metadata_from_cinemeta, | |
| matches_subtitle, | |
| get_search_query_from_filename, | |
| parse_split_info, | |
| is_video_file, | |
| matches_title, | |
| get_mime_type, | |
| parse_audio_languages | |
| ) | |
| from zip_helper import ( | |
| list_zip_files, | |
| TelegramSeekableReader, | |
| get_zip_entry_data_offset, | |
| zip_compressed_generator, | |
| parse_zip_local_headers, | |
| is_video_file | |
| ) | |
| from search_matcher import TelegramSearchMatcher, parse_quality, quality_tier, SCORE_THRESHOLD | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="[%(asctime)s] [%(levelname)s] (%(name)s) - %(message)s", | |
| datefmt="%Y-%m-%d %H:%M:%S" | |
| ) | |
| logger = logging.getLogger("stremio_addon") | |
| async def lifespan(app: FastAPI): | |
| try: | |
| print("\n" + "=" * 60) | |
| print(" TELEGRAM ADDON") | |
| print(" For educational and personal testing only.") | |
| print("=" * 60 + "\n") | |
| try: | |
| Config.validate() | |
| await tg_client_manager.start() | |
| except ValueError as e: | |
| logger.warning( | |
| f"Configuration validation deferred: {e}. " | |
| "Please configure your API credentials through the web setup panel." | |
| ) | |
| yield | |
| finally: | |
| await tg_client_manager.stop() | |
| app = FastAPI(lifespan=lifespan) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| from starlette.middleware.gzip import GZipMiddleware | |
| app.add_middleware(GZipMiddleware, minimum_size=1000) | |
| # In-memory high-speed response caches | |
| _catalog_cache = {} | |
| _stream_cache = {} | |
| _meta_cache = {} | |
| _split_metadata_cache = {} | |
| _subtitles_cache = {} | |
| async def clean_path_middleware(request: Request, call_next): | |
| raw_path = request.scope.get("path", "") | |
| if "manifest" in raw_path: | |
| unquoted = urllib.parse.unquote(raw_path) | |
| cleaned = re.sub(r'manifest[\s\.]*json', 'manifest.json', unquoted) | |
| if cleaned != unquoted: | |
| request.scope["path"] = cleaned | |
| return await call_next(request) | |
| async def disable_proxy_buffering(request: Request, call_next): | |
| """Disable nginx/reverse-proxy buffering for streaming endpoints. | |
| HF Spaces uses nginx which buffers responses by default, causing | |
| multi-minute delays before the video player receives any data.""" | |
| response = await call_next(request) | |
| if "/stream/" in request.url.path: | |
| response.headers["X-Accel-Buffering"] = "no" | |
| return response | |
| def group_tg_messages(messages: list) -> list: | |
| grouped = {} | |
| standalone = [] | |
| for msg in messages: | |
| media = msg.video or msg.document or msg.audio | |
| if not media: | |
| continue | |
| fn = getattr(media, "file_name", "") or msg.caption or f"Telegram File {msg.id}" | |
| base, part = parse_split_info(fn) | |
| if base and part is not None: | |
| key = (msg.chat.id, base.lower()) | |
| if key not in grouped: | |
| grouped[key] = { | |
| "base_name": base, | |
| "parts": {} | |
| } | |
| grouped[key]["parts"][part] = msg | |
| else: | |
| standalone.append(msg) | |
| results = [] | |
| for key, data in grouped.items(): | |
| parts = data["parts"] | |
| base_name = data["base_name"] | |
| if len(parts) == 1: | |
| results.append(list(parts.values())[0]) | |
| else: | |
| sorted_parts = [msg for part, msg in sorted(parts.items())] | |
| results.append((base_name, sorted_parts)) | |
| for msg in standalone: | |
| results.append(msg) | |
| return results | |
| def verify_api_key(request: Request): | |
| if Config.API_KEY: | |
| api_key = request.query_params.get("api_key", "") or request.path_params.get("api_key", "") | |
| if api_key != Config.API_KEY: | |
| raise HTTPException(status_code=403, detail="Unauthorized: Invalid API Key") | |
| def get_manifest(api_key: str = ""): | |
| query_suffix = f"?api_key={api_key}" if api_key else "" | |
| catalogs = [] | |
| # Browsable catalog for configured channel(s) | |
| if Config.TELEGRAM_CHANNEL_ID: | |
| catalogs.append({ | |
| "type": "movie", | |
| "id": "telegram_channel", | |
| "name": "Telegram Channel", | |
| "extra": [{"name": "skip"}] | |
| }) | |
| # Global search catalog (disabled by default; opt-in via ENABLE_TELEGRAM_SEARCH / ENABLE_SEARCH) | |
| if Config.ENABLE_TELEGRAM_SEARCH: | |
| catalogs.append({ | |
| "type": "movie", | |
| "id": "telegram_search", | |
| "name": "Telegram Search", | |
| "extra": [{"name": "search", "isRequired": True}, {"name": "skip"}], | |
| }) | |
| return { | |
| "id": "community.telegram.stremio.addon", | |
| "version": "1.0.0", | |
| "name": "Telegram", | |
| "description": "Personal Telegram streaming proxy. For educational & personal testing only. Do not use for unauthorized hosting of copyrighted media.", | |
| "logo": "https://upload.wikimedia.org/wikipedia/commons/8/82/Telegram_logo.svg", | |
| "resources": ["meta", "stream", "subtitles"], | |
| "types": ["movie", "series"], | |
| "idPrefixes": ["tgfile_", "tt"], | |
| "catalogs": catalogs, | |
| "behaviorHints": { | |
| "configurable": True, | |
| "configurationRequired": False | |
| } | |
| } | |
| async def landing(request: Request): | |
| addon_base_url = get_addon_url(request) | |
| api_key = request.query_params.get("api_key", "") | |
| if api_key: | |
| manifest_url = f"{addon_base_url}/{urllib.parse.quote(api_key)}/manifest.json" | |
| else: | |
| manifest_url = f"{addon_base_url}/manifest.json" | |
| escaped_manifest_url = markupsafe.escape(manifest_url) | |
| escaped_stremio_url = markupsafe.escape(manifest_url.replace('http://', '').replace('https://', '')) | |
| web_stremio_url = f"https://web.stremio.com/#/addons?addon={urllib.parse.quote(manifest_url)}" | |
| escaped_web_stremio_url = markupsafe.escape(web_stremio_url) | |
| api_key_section = "" | |
| if Config.API_KEY: | |
| escaped_api_key = markupsafe.escape(api_key) | |
| api_key_section = f""" | |
| <div class="url-section" style="margin-bottom: 16px;"> | |
| <div class="section-title">Enter API Key</div> | |
| <div class="input-group"> | |
| <input class="url-box" id="apiKeyInput" type="text" placeholder="Enter your API Key..." value="{escaped_api_key}" oninput="updateManifestUrl()"> | |
| </div> | |
| </div> | |
| """ | |
| html_content = f""" | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Stremio Telegram Addon</title> | |
| <meta name="description" content="Stream private Telegram files directly inside Stremio. Secure, lightweight, and ranges-supported proxy."> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| :root {{ | |
| --bg-dark: #09090b; | |
| --bg-card: #18181b; | |
| --border-muted: #27272a; | |
| --text-primary: #f4f4f5; | |
| --text-secondary: #a1a1aa; | |
| --text-muted: #71717a; | |
| --color-primary: #2563eb; | |
| --color-primary-hover: #1d4ed8; | |
| --color-accent: #60a5fa; | |
| --font-title: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| --font-body: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| }} | |
| * {{ | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| }} | |
| body {{ | |
| font-family: var(--font-body); | |
| background-color: var(--bg-dark); | |
| color: var(--text-primary); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 100vh; | |
| padding: 40px 20px; | |
| margin: 0; | |
| overflow-x: hidden; | |
| }} | |
| .app-card {{ | |
| background-color: var(--bg-card); | |
| border: 1px solid var(--border-muted); | |
| border-radius: 12px; | |
| padding: 40px; | |
| width: 100%; | |
| max-width: 680px; | |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); | |
| position: relative; | |
| }} | |
| .nav-header {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 32px; | |
| }} | |
| .brand {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| font-family: var(--font-title); | |
| font-weight: 700; | |
| font-size: 1.1rem; | |
| letter-spacing: -0.02em; | |
| color: var(--text-primary); | |
| }} | |
| .brand-logo {{ | |
| width: 28px; | |
| height: 28px; | |
| }} | |
| .star-badge {{ | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| background: linear-gradient(135deg, #fbbf24 0%, #d97706 100%); | |
| color: #09090b; | |
| padding: 8px 14px; | |
| border-radius: 6px; | |
| font-size: 0.78rem; | |
| font-weight: 700; | |
| text-decoration: none; | |
| box-shadow: 0 0 15px rgba(251, 191, 36, 0.3); | |
| transition: all 0.3s ease; | |
| }} | |
| .star-badge:hover {{ | |
| transform: translateY(-2px); | |
| box-shadow: 0 0 20px rgba(251, 191, 36, 0.6); | |
| color: #000000; | |
| }} | |
| .hero {{ | |
| text-align: center; | |
| margin-bottom: 32px; | |
| }} | |
| .hero h1 {{ | |
| font-family: var(--font-title); | |
| font-size: 2rem; | |
| font-weight: 700; | |
| line-height: 1.25; | |
| letter-spacing: -0.02em; | |
| margin: 8px 0 16px 0; | |
| color: #ffffff; | |
| }} | |
| .hero p {{ | |
| font-size: 0.95rem; | |
| color: var(--text-secondary); | |
| line-height: 1.5; | |
| max-width: 520px; | |
| margin: 0 auto; | |
| }} | |
| .url-section {{ | |
| background: #09090b; | |
| border: 1px solid var(--border-muted); | |
| border-radius: 8px; | |
| padding: 20px; | |
| margin-bottom: 24px; | |
| }} | |
| .section-title {{ | |
| font-family: var(--font-title); | |
| font-size: 0.8rem; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| color: var(--text-secondary); | |
| margin-bottom: 12px; | |
| }} | |
| .input-group {{ | |
| display: flex; | |
| gap: 10px; | |
| }} | |
| .url-box {{ | |
| flex: 1; | |
| background-color: #18181b; | |
| border: 1px solid #27272a; | |
| color: var(--text-primary); | |
| padding: 12px 16px; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| font-family: monospace; | |
| outline: none; | |
| transition: border-color 0.2s; | |
| }} | |
| .url-box:focus {{ | |
| border-color: var(--color-primary); | |
| }} | |
| .btn-copy {{ | |
| background: #27272a; | |
| border: 1px solid #3f3f46; | |
| color: var(--text-primary); | |
| padding: 0 16px; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| font-weight: 500; | |
| cursor: pointer; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| transition: all 0.2s; | |
| }} | |
| .btn-copy:hover {{ | |
| background: #3f3f46; | |
| border-color: #52525b; | |
| }} | |
| .button-group {{ | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| gap: 12px; | |
| margin-bottom: 32px; | |
| }} | |
| @media (min-width: 520px) {{ | |
| .button-group {{ | |
| grid-template-columns: 1fr 1fr; | |
| }} | |
| }} | |
| .btn {{ | |
| padding: 12px 20px; | |
| font-family: var(--font-body); | |
| font-size: 0.9rem; | |
| font-weight: 500; | |
| text-decoration: none; | |
| border-radius: 6px; | |
| text-align: center; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| transition: all 0.2s; | |
| }} | |
| .btn-primary {{ | |
| background-color: var(--color-primary); | |
| color: #ffffff; | |
| }} | |
| .btn-primary:hover {{ | |
| background-color: var(--color-primary-hover); | |
| }} | |
| .btn-secondary {{ | |
| background: #27272a; | |
| border: 1px solid #3f3f46; | |
| color: var(--text-primary); | |
| }} | |
| .btn-secondary:hover {{ | |
| background: #3f3f46; | |
| border-color: #52525b; | |
| }} | |
| .troubleshoot-details {{ | |
| background: #09090b; | |
| border: 1px solid var(--border-muted); | |
| border-radius: 8px; | |
| padding: 16px; | |
| margin-bottom: 24px; | |
| }} | |
| .troubleshoot-summary {{ | |
| font-family: var(--font-title); | |
| font-size: 0.9rem; | |
| font-weight: 600; | |
| color: var(--text-primary); | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| user-select: none; | |
| outline: none; | |
| }} | |
| .troubleshoot-content {{ | |
| margin-top: 14px; | |
| font-size: 0.85rem; | |
| color: var(--text-secondary); | |
| line-height: 1.5; | |
| border-top: 1px solid #27272a; | |
| padding-top: 14px; | |
| }} | |
| .troubleshoot-content ol {{ | |
| margin-left: 20px; | |
| margin-top: 8px; | |
| }} | |
| .troubleshoot-content li {{ | |
| margin-bottom: 6px; | |
| }} | |
| .features-grid {{ | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| gap: 16px; | |
| margin-bottom: 32px; | |
| }} | |
| @media (min-width: 600px) {{ | |
| .features-grid {{ | |
| grid-template-columns: 1fr 1fr; | |
| }} | |
| }} | |
| .feature-card {{ | |
| background: #18181b; | |
| border: 1px solid var(--border-muted); | |
| border-radius: 8px; | |
| padding: 20px; | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app-card"> | |
| <div class="nav-header"> | |
| <div class="brand"> | |
| <svg class="brand-logo" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="url(#logoGrad)"/> | |
| <path fill-rule="evenodd" clip-rule="evenodd" d="M16.974 8.23272C17.1568 7.2796 16.2004 6.5492 15.3533 6.94008L6.46743 11.0398C5.72727 11.3813 5.76103 12.4431 6.51651 12.7336L8.85507 13.6331C9.52554 13.891 10.2831 13.7828 10.8553 13.3486L14.4754 10.6011C14.6195 10.4917 14.7766 10.7042 14.6534 10.8406L11.597 14.2238C11.107 14.7663 11.2335 15.6322 11.854 16.015L15.3854 18.1936C16.1471 18.6635 17.1264 18.0673 17.0792 17.1685L16.974 8.23272Z" fill="white"/> | |
| <defs> | |
| <linearGradient id="logoGrad" x1="2" y1="2" x2="22" y2="22" gradientUnits="userSpaceOnUse"> | |
| <stop stop-color="#3b82f6"/> | |
| <stop offset="1" stop-color="#1d4ed8"/> | |
| </linearGradient> | |
| </defs> | |
| </svg> | |
| Stremio Telegram Addon | |
| </div> | |
| </div> | |
| <div class="hero"> | |
| <h1>Stremio Telegram Addon</h1> | |
| <p>A self-hosted Stremio addon proxy to stream videos, audios, and segmented archive parts directly from Telegram.</p> | |
| </div> | |
| {api_key_section} | |
| <div class="url-section"> | |
| <div class="section-title">Addon Manifest URL</div> | |
| <div class="input-group"> | |
| <input class="url-box" id="manifestUrl" type="text" readonly value="{escaped_manifest_url}"> | |
| <button class="btn-copy" id="btnCopy" onclick="copyManifestUrl()"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-copy"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg> | |
| <span id="btnCopyText">Copy</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="button-group"> | |
| <a class="btn btn-primary" id="installApp" href="stremio://{escaped_stremio_url}"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg> | |
| Install on Stremio App | |
| </a> | |
| <a class="btn btn-secondary" id="installWeb" href="{escaped_web_stremio_url}" target="_blank"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg> | |
| Install on Stremio Web | |
| </a> | |
| </div> | |
| <details class="troubleshoot-details"> | |
| <summary class="troubleshoot-summary"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px; color: #fbbf24;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg> | |
| Local Deployment Troubleshooting | |
| </summary> | |
| <div class="troubleshoot-content"> | |
| This error <strong>only occurs in local HTTP deployments</strong>. If you deploy this project to a secure public HTTPS server (such as via Hugging Face Spaces), this installation button will work <strong>flawlessly</strong>. | |
| <br><br> | |
| For local deployments, Stremio's desktop protocol handler (<strong>stremio://</strong>) strips local ports and forces HTTPS, resulting in connection failure. | |
| <br><br> | |
| <strong>How to install locally:</strong> | |
| <ol> | |
| <li>Click the <strong>Copy</strong> button on the manifest URL field above.</li> | |
| <li>Open the <strong>Stremio Desktop App</strong>.</li> | |
| <li>Navigate to <strong>Add-ons</strong> (puzzle icon in the sidebar).</li> | |
| <li>Paste the copied URL directly into the <strong>Add-on Repository URL</strong> input box at the bottom and click <strong>Install</strong>.</li> | |
| <li>Alternatively, use the <strong>Install on Stremio Web</strong> button above.</li> | |
| </ol> | |
| </div> | |
| </details> | |
| </div> | |
| <script> | |
| const baseManifestUrl = "{addon_base_url}/manifest.json"; | |
| const baseStremioUrl = baseManifestUrl.replace('http://', '').replace('https://', ''); | |
| function updateManifestUrl() {{ | |
| const apiKeyInput = document.getElementById("apiKeyInput"); | |
| const manifestUrlEl = document.getElementById("manifestUrl"); | |
| const installAppEl = document.getElementById("installApp"); | |
| const installWebEl = document.getElementById("installWeb"); | |
| let apiKey = ""; | |
| if (apiKeyInput) {{ | |
| apiKey = apiKeyInput.value.trim(); | |
| }} else {{ | |
| apiKey = new URLSearchParams(window.location.search).get("api_key") || ""; | |
| }} | |
| let manifestUrl = baseManifestUrl; | |
| let stremioUrl = baseStremioUrl; | |
| if (apiKey) {{ | |
| const encodedKey = encodeURIComponent(apiKey); | |
| manifestUrl = "{addon_base_url}/" + encodedKey + "/manifest.json"; | |
| stremioUrl = baseStremioUrl.replace("manifest.json", encodedKey + "/manifest.json"); | |
| }} | |
| if (manifestUrlEl) {{ | |
| manifestUrlEl.value = manifestUrl; | |
| }} | |
| if (installAppEl) {{ | |
| installAppEl.href = "stremio://" + stremioUrl; | |
| }} | |
| if (installWebEl) {{ | |
| installWebEl.href = "https://web.stremio.com/#/addons?addon=" + encodeURIComponent(manifestUrl); | |
| }} | |
| }} | |
| function copyManifestUrl() {{ | |
| var copyText = document.getElementById("manifestUrl"); | |
| copyText.select(); | |
| copyText.setSelectionRange(0, 99999); | |
| navigator.clipboard.writeText(copyText.value); | |
| var btnText = document.getElementById("btnCopyText"); | |
| var originalText = btnText.innerHTML; | |
| btnText.innerHTML = "Copied!"; | |
| var copyBtn = document.getElementById("btnCopy"); | |
| copyBtn.style.background = "#22c55e"; | |
| copyBtn.style.borderColor = "#22c55e"; | |
| copyBtn.style.color = "#ffffff"; | |
| setTimeout(function() {{ | |
| btnText.innerHTML = originalText; | |
| copyBtn.style.background = ""; | |
| copyBtn.style.borderColor = ""; | |
| copyBtn.style.color = ""; | |
| }}, 2000); | |
| }} | |
| window.onload = function() {{ | |
| updateManifestUrl(); | |
| }}; | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| return HTMLResponse(content=html_content) | |
| async def manifest_endpoint(request: Request, api_key: str = ""): | |
| actual_key = api_key or request.query_params.get("api_key", "") | |
| if Config.API_KEY and actual_key != Config.API_KEY: | |
| return JSONResponse({"detail": "Unauthorized: Invalid API Key"}, status_code=403) | |
| return get_manifest(actual_key) | |
| async def catalog_handler( | |
| type: str, | |
| catalog_id: str, | |
| request: Request, | |
| extra: str = None, | |
| api_key: str = "" | |
| ): | |
| addon_base_url = get_addon_url(request) | |
| if type not in ["movie", "series", "other"]: | |
| return {"metas": []} | |
| if catalog_id == "telegram_search" and not Config.ENABLE_TELEGRAM_SEARCH: | |
| return {"metas": []} | |
| query = "" | |
| skip = 0 | |
| if extra: | |
| params = urllib.parse.parse_qs(extra) | |
| if "search" in params: | |
| query = params["search"][0] | |
| if "skip" in params: | |
| try: | |
| skip = int(params["skip"][0]) | |
| except ValueError: | |
| pass | |
| cache_key = f"{catalog_id}:{type}:{skip}:{query}" | |
| now = time.time() | |
| if cache_key in _catalog_cache: | |
| c_time, c_data = _catalog_cache[cache_key] | |
| if now - c_time < 300: | |
| return JSONResponse( | |
| content=c_data, | |
| headers={"Cache-Control": "public, max-age=300, stale-while-revalidate=600", "X-Cache": "HIT"} | |
| ) | |
| browse_limit = max(100, min(skip + 60, 300)) if catalog_id == "telegram_channel" else 50 | |
| try: | |
| messages = await tg_client_manager.search_messages(query=query, limit=browse_limit) | |
| except Exception as e: | |
| logger.error(f"Catalog search failed: {e}") | |
| return {"metas": []} | |
| grouped_items = group_tg_messages(messages) | |
| # Sort grouped items by date (newest first) | |
| def get_item_date(item): | |
| if isinstance(item, tuple): | |
| base_name, parts = item | |
| return max((msg.date for msg in parts if msg.date), default=0) | |
| else: | |
| return item.date if item.date else 0 | |
| grouped_items.sort(key=get_item_date, reverse=True) | |
| # Deduplicate catalog items to avoid showing duplicate files | |
| deduped_items = [] | |
| seen_cat_uids = set() | |
| seen_cat_names_sizes = set() | |
| for item in grouped_items: | |
| if isinstance(item, tuple): | |
| base_name, parts = item | |
| total_size = sum((x.video or x.document or x.audio).file_size for x in parts if (x.video or x.document or x.audio)) | |
| raw_sig = (base_name.strip().lower(), total_size) | |
| clean_sig = (normalize_release_name(base_name), total_size) | |
| if raw_sig in seen_cat_names_sizes or clean_sig in seen_cat_names_sizes: | |
| continue | |
| seen_cat_names_sizes.add(raw_sig) | |
| seen_cat_names_sizes.add(clean_sig) | |
| deduped_items.append(item) | |
| else: | |
| msg = item | |
| media = msg.video or msg.document or msg.audio | |
| if not media: | |
| continue | |
| uid = getattr(media, "file_unique_id", None) | |
| if uid and uid in seen_cat_uids: | |
| continue | |
| file_name = getattr(media, "file_name", None) or msg.caption or f"Telegram File {msg.id}" | |
| file_size = getattr(media, "file_size", 0) | |
| raw_sig = (file_name.strip().lower(), file_size) | |
| clean_sig = (normalize_release_name(file_name), file_size) | |
| if raw_sig in seen_cat_names_sizes or clean_sig in seen_cat_names_sizes: | |
| continue | |
| if uid: | |
| seen_cat_uids.add(uid) | |
| seen_cat_names_sizes.add(raw_sig) | |
| seen_cat_names_sizes.add(clean_sig) | |
| deduped_items.append(item) | |
| grouped_items = deduped_items | |
| # Slice for pagination (50 items per page) | |
| sliced_items = grouped_items[skip : skip + 50] | |
| metas = [] | |
| logo_url = f"{get_addon_url(request)}/stremio_telegram_logo.png" if getattr(Config, "ADDON_URL", None) else None | |
| def get_poster_url(msg): | |
| """Use video thumbnail as poster if available, otherwise fall back to logo.""" | |
| media = msg.video or msg.document or msg.audio | |
| if media and getattr(media, 'thumbs', None): | |
| return f"{get_addon_url(request)}/poster/{msg.chat.id}/{msg.id}" | |
| return logo_url | |
| for item in sliced_items: | |
| if isinstance(item, tuple): | |
| base_name, parts = item | |
| total_size = sum((x.video or x.document or x.audio).file_size for x in parts if (x.video or x.document or x.audio)) | |
| first_msg = parts[0] | |
| chat_id = first_msg.chat.id | |
| msg_ids = ",".join(str(x.id) for x in parts) | |
| is_zip = False | |
| if base_name.lower().endswith(".zip"): | |
| try: | |
| entries = await list_zip_files(tg_client_manager.client, parts) | |
| video_entries = [e for e in entries if is_video_file(e.filename)] | |
| if video_entries: | |
| is_zip = True | |
| for entry in video_entries: | |
| tg_id = f"tgfile_splitzip_{chat_id}_{msg_ids}//{entry.filename}" | |
| metas.append({ | |
| "id": tg_id, | |
| "type": type, | |
| "name": entry.filename, | |
| "description": f"💾 Telegram ZIP Entry\n📦 Size: {format_size(entry.file_size)}\n📂 ZIP Archive: {base_name}", | |
| "poster": get_poster_url(first_msg), | |
| }) | |
| except Exception as e: | |
| logger.error(f"Error reading split ZIP archive: {e}") | |
| if not is_zip: | |
| tg_id = f"tgfile_split_{chat_id}_{msg_ids}" | |
| metas.append({ | |
| "id": tg_id, | |
| "type": type, | |
| "name": base_name, | |
| "description": f"💾 Telegram File (Split Parts: {len(parts)})\n📦 Total Size: {format_size(total_size)}", | |
| "poster": get_poster_url(first_msg), | |
| }) | |
| else: | |
| msg = item | |
| media = msg.video or msg.document or msg.audio | |
| file_name = getattr(media, "file_name", None) or msg.caption or f"Telegram File {msg.id}" | |
| file_size = media.file_size | |
| caption = msg.caption or "" | |
| is_zip = False | |
| if file_name.lower().endswith(".zip"): | |
| try: | |
| entries = await list_zip_files(tg_client_manager.client, msg) | |
| video_entries = [e for e in entries if is_video_file(e.filename)] | |
| if video_entries: | |
| is_zip = True | |
| for entry in video_entries: | |
| tg_id = f"tgfile_zip_{msg.chat.id}_{msg.id}//{entry.filename}" | |
| metas.append({ | |
| "id": tg_id, | |
| "type": type, | |
| "name": entry.filename, | |
| "description": f"💾 Telegram ZIP Entry\n📦 Size: {format_size(entry.file_size)}\n📂 ZIP Archive: {file_name}", | |
| "poster": get_poster_url(msg), | |
| }) | |
| except Exception as e: | |
| logger.error(f"Error reading standalone ZIP archive: {e}") | |
| if not is_zip: | |
| tg_id = f"tgfile_{msg.chat.id}_{msg.id}" | |
| metas.append({ | |
| "id": tg_id, | |
| "type": type, | |
| "name": file_name, | |
| "description": f"💾 Telegram File\n📦 Size: {format_size(file_size)}\n💬 {caption}" if caption else f"💾 Telegram File\n📦 Size: {format_size(file_size)}", | |
| "poster": get_poster_url(msg), | |
| }) | |
| response_data = { | |
| "metas": metas, | |
| "cacheMaxAge": 300, | |
| "staleRevalidate": 600 | |
| } | |
| _catalog_cache[cache_key] = (now, response_data) | |
| return JSONResponse( | |
| content=response_data, | |
| headers={"Cache-Control": "public, max-age=300, stale-while-revalidate=600", "X-Cache": "MISS"} | |
| ) | |
| from fastapi.responses import FileResponse | |
| import os | |
| async def get_logo(): | |
| if os.path.exists("stremio_telegram_logo.png") and os.path.getsize("stremio_telegram_logo.png") > 1000: | |
| return FileResponse("stremio_telegram_logo.png", media_type="image/png") | |
| svg_logo = """<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"> | |
| <defs> | |
| <linearGradient id="g1" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| <stop offset="0%" stop-color="#2563eb"/> | |
| <stop offset="100%" stop-color="#7c3aed"/> | |
| </linearGradient> | |
| </defs> | |
| <rect width="256" height="256" rx="56" fill="url(#g1)"/> | |
| <path d="M72 128 L112 168 L184 88" fill="none" stroke="#ffffff" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/> | |
| </svg>""" | |
| return Response(content=svg_logo, media_type="image/svg+xml") | |
| async def get_banner(): | |
| if os.path.exists("stremio_telegram_banner.png") and os.path.getsize("stremio_telegram_banner.png") > 1000: | |
| return FileResponse("stremio_telegram_banner.png", media_type="image/png") | |
| svg_banner = """<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="400" viewBox="0 0 1200 400"> | |
| <defs> | |
| <linearGradient id="gb" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| <stop offset="0%" stop-color="#0f172a"/> | |
| <stop offset="100%" stop-color="#1e1b4b"/> | |
| </linearGradient> | |
| </defs> | |
| <rect width="1200" height="400" fill="url(#gb)"/> | |
| <text x="600" y="215" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="56" font-weight="bold" fill="#f8fafc" text-anchor="middle">Telegram Stremio Addon</text> | |
| </svg>""" | |
| return Response(content=svg_banner, media_type="image/svg+xml") | |
| _poster_cache = {} | |
| async def poster_handler(chat_id: str, msg_id: int): | |
| """Serve video thumbnail from Telegram as poster image.""" | |
| cache_key = f"{chat_id}:{msg_id}" | |
| if cache_key in _poster_cache: | |
| return Response(content=_poster_cache[cache_key], media_type="image/jpeg", | |
| headers={"Cache-Control": "public, max-age=86400"}) | |
| try: | |
| try: | |
| chat_id_val = int(chat_id) | |
| except ValueError: | |
| chat_id_val = chat_id | |
| msg = await tg_client_manager.get_message(msg_id, chat_id=chat_id_val) | |
| if not msg: | |
| return Response(status_code=404) | |
| media = msg.video or msg.document or msg.audio | |
| if not media or not getattr(media, 'thumbs', None): | |
| return Response(status_code=404) | |
| thumb = sorted(media.thumbs, key=lambda t: (t.width or 0) * (t.height or 0))[-1] | |
| file = await tg_client_manager.client.download_media(thumb.file_id, in_memory=True) | |
| if file: | |
| data = file.getvalue() if hasattr(file, 'getvalue') else bytes(file) | |
| _poster_cache[cache_key] = data | |
| return Response(content=data, media_type="image/jpeg", | |
| headers={"Cache-Control": "public, max-age=86400"}) | |
| return Response(status_code=404) | |
| except Exception as e: | |
| logger.error(f"Failed to serve poster for {chat_id}/{msg_id}: {e}") | |
| return Response(status_code=404) | |
| async def meta_handler(type: str, meta_id: str, request: Request, api_key: str = ""): | |
| if not meta_id.startswith("tgfile_"): | |
| return {"meta": {}} | |
| now = time.time() | |
| if meta_id in _meta_cache: | |
| c_time, c_data = _meta_cache[meta_id] | |
| if now - c_time < 86400: | |
| return JSONResponse( | |
| content=c_data, | |
| headers={"Cache-Control": "public, max-age=86400", "X-Cache": "HIT"} | |
| ) | |
| try: | |
| is_zip_entry = False | |
| zip_entry_filename = "" | |
| base_meta_id = meta_id | |
| if "//" in meta_id: | |
| is_zip_entry = True | |
| base_meta_id, zip_entry_filename = meta_id.split("//", 1) | |
| chat_id_val = None | |
| msg_ids_str = "" | |
| is_split = False | |
| if base_meta_id.startswith("tgfile_splitzip_"): | |
| is_split = True | |
| parts = base_meta_id.split("_") | |
| chat_id = parts[2] | |
| msg_ids_str = parts[3] | |
| elif base_meta_id.startswith("tgfile_split_"): | |
| is_split = True | |
| parts = base_meta_id.split("_") | |
| chat_id = parts[2] | |
| msg_ids_str = parts[3] | |
| elif base_meta_id.startswith("tgfile_zip_"): | |
| parts = base_meta_id.split("_") | |
| chat_id = parts[2] | |
| msg_ids_str = parts[3] | |
| else: | |
| parts = base_meta_id.split("_") | |
| chat_id = parts[1] | |
| msg_ids_str = parts[2] | |
| try: | |
| chat_id_val = int(chat_id) | |
| except ValueError: | |
| chat_id_val = chat_id | |
| msg_id_list = [int(x) for x in msg_ids_str.split(",") if x.strip().isdigit()] | |
| messages = await tg_client_manager.get_messages(msg_id_list, chat_id=chat_id_val) | |
| if not messages: | |
| return {"meta": {}} | |
| first_msg = messages[0] | |
| media = first_msg.video or first_msg.document or first_msg.audio | |
| if not media: | |
| return {"meta": {}} | |
| first_fn = getattr(media, "file_name", "video.mp4") or "video.mp4" | |
| if is_zip_entry and zip_entry_filename: | |
| file_name = zip_entry_filename | |
| zip_entries = await list_zip_files(tg_client_manager.client, messages) | |
| file_size = 0 | |
| for entry in zip_entries: | |
| if entry.filename == zip_entry_filename: | |
| file_size = entry.file_size | |
| break | |
| description = f"💾 Telegram ZIP Entry\n📦 Size: {format_size(file_size)}\n📂 ZIP Archive: {first_fn}" | |
| else: | |
| file_name = first_fn | |
| if is_split: | |
| base_name, _ = parse_split_info(first_fn) | |
| file_name = base_name or first_fn | |
| total_size = sum((x.video or x.document or x.audio).file_size for x in messages if (x.video or x.document or x.audio)) | |
| description = f"💾 Telegram File (Split Parts: {len(messages)})\n📦 Total Size: {format_size(total_size)}" | |
| else: | |
| total_size = media.file_size | |
| caption = first_msg.caption or "" | |
| description = f"💾 Telegram File\n📦 Size: {format_size(total_size)}\n💬 {caption}" if caption else f"💾 Telegram File\n📦 Size: {format_size(total_size)}" | |
| logo_url = f"{get_addon_url(request)}/stremio_telegram_logo.png" if getattr(Config, "ADDON_URL", None) else None | |
| poster_url = logo_url | |
| if media and getattr(media, 'thumbs', None): | |
| poster_url = f"{get_addon_url(request)}/poster/{chat_id_val}/{first_msg.id}" | |
| meta = { | |
| "id": meta_id, | |
| "type": type, | |
| "name": file_name, | |
| "description": description, | |
| "poster": poster_url, | |
| "background": f"{get_addon_url(request)}/stremio_telegram_banner.png" if getattr(Config, "ADDON_URL", None) else None, | |
| "logo": logo_url, | |
| } | |
| if type == "series": | |
| meta["videos"] = [ | |
| { | |
| "id": meta_id, | |
| "title": file_name, | |
| "season": 1, | |
| "episode": 1 | |
| } | |
| ] | |
| response_data = { | |
| "meta": meta, | |
| "cacheMaxAge": 86400 | |
| } | |
| _meta_cache[meta_id] = (now, response_data) | |
| return JSONResponse( | |
| content=response_data, | |
| headers={"Cache-Control": "public, max-age=86400", "X-Cache": "MISS"} | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to generate metadata for {meta_id}: {e}") | |
| return {"meta": {}} | |
| async def find_subtitles_for_video(video_filename: str, request: Request = None, api_key: str = "", cached_messages=None) -> list: | |
| sub_cache_key = f"{video_filename}:{api_key}" | |
| if sub_cache_key in _subtitles_cache: | |
| return _subtitles_cache[sub_cache_key] | |
| subtitles = [] | |
| search_results = cached_messages or [] | |
| query_param = f"?api_key={api_key}" if api_key else "" | |
| if not search_results: | |
| query = get_search_query_from_filename(video_filename) | |
| if query: | |
| try: | |
| search_results = await tg_client_manager.search_messages(query=query, limit=10) | |
| except Exception as e: | |
| logger.debug(f"Subtitle search failed for '{query}': {e}") | |
| seen_msg_ids = set() | |
| seen_sub_sigs = set() | |
| for msg in search_results: | |
| if (msg.chat.id, msg.id) in seen_msg_ids: | |
| continue | |
| doc = msg.document or msg.audio or msg.video | |
| if not doc: | |
| continue | |
| sub_fn = getattr(doc, "file_name", "") or "" | |
| if sub_fn.lower().endswith(('.srt', '.vtt', '.ass')): | |
| if matches_subtitle(video_filename, sub_fn): | |
| seen_msg_ids.add((msg.chat.id, msg.id)) | |
| sub_sig = (sub_fn.lower().strip(), getattr(doc, "file_size", 0)) | |
| if sub_sig in seen_sub_sigs: | |
| continue | |
| seen_sub_sigs.add(sub_sig) | |
| lang = "eng" | |
| sub_fn_lower = sub_fn.lower() | |
| if ".spa" in sub_fn_lower or "spanish" in sub_fn_lower: | |
| lang = "spa" | |
| elif ".fre" in sub_fn_lower or "french" in sub_fn_lower: | |
| lang = "fre" | |
| sub_url = f"{get_addon_url(request)}/stream/subtitle/{msg.chat.id}/{msg.id}/{urllib.parse.quote(sub_fn)}{query_param}" | |
| subtitles.append({ | |
| "id": f"tgsub_{msg.chat.id}_{msg.id}", | |
| "url": sub_url, | |
| "lang": lang | |
| }) | |
| _subtitles_cache[sub_cache_key] = subtitles | |
| return subtitles | |
| async def stream_handler( | |
| type: str, | |
| stream_id: str, | |
| request: Request, | |
| api_key: str = "" | |
| ): | |
| addon_base_url = get_addon_url(request) | |
| if Config.API_KEY: | |
| actual_key = api_key or request.query_params.get("api_key", "") | |
| if actual_key != Config.API_KEY: | |
| raise HTTPException(status_code=403, detail="Unauthorized") | |
| hide_split_param = request.query_params.get("hide_split") | |
| if hide_split_param is not None: | |
| hide_split_streams = hide_split_param.strip().lower() in ("true", "1", "yes", "on") | |
| else: | |
| hide_split_streams = getattr(Config, "HIDE_SPLIT_STREAMS", False) | |
| cache_key = f"{type}:{stream_id}:{hide_split_streams}" | |
| now = time.time() | |
| if cache_key in _stream_cache: | |
| c_time, c_data = _stream_cache[cache_key] | |
| if now - c_time < 300: | |
| return JSONResponse( | |
| content=c_data, | |
| headers={"Cache-Control": "public, max-age=300, stale-while-revalidate=60", "X-Cache": "HIT"} | |
| ) | |
| streams = [] | |
| query_param = f"?api_key={api_key}" if api_key else "" | |
| if stream_id.startswith("tgfile_"): | |
| if "//" in stream_id: | |
| base_stream_id, zip_entry_filename = stream_id.split("//", 1) | |
| is_split = False | |
| if base_stream_id.startswith("tgfile_splitzip_"): | |
| is_split = True | |
| parts = base_stream_id.split("_") | |
| chat_id = parts[2] | |
| msg_ids = parts[3] | |
| elif base_stream_id.startswith("tgfile_split_"): | |
| is_split = True | |
| parts = base_stream_id.split("_") | |
| chat_id = parts[2] | |
| msg_ids = parts[3] | |
| elif base_stream_id.startswith("tgfile_zip_"): | |
| parts = base_stream_id.split("_") | |
| chat_id = parts[2] | |
| msg_ids = parts[3] | |
| else: | |
| parts = base_stream_id.split("_") | |
| chat_id = parts[1] | |
| msg_ids = parts[2] | |
| try: | |
| chat_id_val = int(chat_id) | |
| except ValueError: | |
| chat_id_val = chat_id | |
| msg_id_list = [int(x) for x in msg_ids.split(",") if x.strip().isdigit()] | |
| try: | |
| messages = await tg_client_manager.get_messages(msg_id_list, chat_id=chat_id_val) | |
| if messages: | |
| zip_entries = await list_zip_files(tg_client_manager.client, messages) | |
| file_size = 0 | |
| for entry in zip_entries: | |
| if entry.filename == zip_entry_filename: | |
| file_size = entry.file_size | |
| break | |
| stream_url = f"{get_addon_url(request)}/stream/zip/{chat_id}/{msg_ids}/{urllib.parse.quote(zip_entry_filename)}{query_param}" | |
| subtitles = await find_subtitles_for_video(zip_entry_filename, request=request, api_key=api_key) | |
| lang_label = parse_audio_languages(zip_entry_filename, default="Stream ZIP entry") | |
| streams.append({ | |
| "name": "▶ Telegram ZIP", | |
| "title": f"{zip_entry_filename}\n💾 {lang_label} | 📦 {format_size(file_size)}", | |
| "url": stream_url, | |
| "subtitles": subtitles, | |
| "behaviorHints": { | |
| "notWebReady": True, | |
| } | |
| }) | |
| except Exception as e: | |
| logger.error(f"Failed resolving zip stream for {stream_id}: {e}") | |
| elif stream_id.startswith("tgfile_split_"): | |
| parts = stream_id.split("_") | |
| if len(parts) >= 4: | |
| chat_id = parts[2] | |
| msg_ids = parts[3] | |
| try: | |
| msg_id_list = [int(x) for x in msg_ids.split(",") if x.isdigit()] | |
| try: | |
| chat_id_val = int(chat_id) | |
| except ValueError: | |
| chat_id_val = chat_id | |
| split_msgs = await tg_client_manager.get_messages(msg_id_list, chat_id=chat_id_val) | |
| if not split_msgs: | |
| raise ValueError(f"No messages resolved for {msg_ids}") | |
| first_msg = split_msgs[0] | |
| media = first_msg.video or first_msg.document or first_msg.audio | |
| first_fn = getattr(media, "file_name", "video.mp4") or "video.mp4" | |
| base_name, _ = parse_split_info(first_fn) | |
| if not base_name: | |
| base_name = first_fn | |
| total_size = sum((m.video or m.document or m.audio).file_size for m in split_msgs if (m.video or m.document or m.audio)) | |
| stream_url = f"{get_addon_url(request)}/stream/split/{chat_id}/{msg_ids}/{urllib.parse.quote(base_name)}{query_param}" | |
| first_caption = getattr(first_msg, "caption", "") or "" | |
| lang_label = parse_audio_languages(base_name, caption=first_caption, default="Stitch stream") | |
| streams.append({ | |
| "name": "▶ Telegram (Split)", | |
| "title": f"{base_name}\n💾 {lang_label} | 📦 {format_size(total_size)}", | |
| "url": stream_url, | |
| "behaviorHints": { | |
| "notWebReady": True, | |
| } | |
| }) | |
| except Exception as e: | |
| logger.error(f"Failed resolving split stream for {stream_id}: {e}") | |
| else: | |
| parts = stream_id.split("_") | |
| if len(parts) >= 3: | |
| chat_id = parts[1] | |
| msg_id = parts[2] | |
| try: | |
| try: | |
| chat_id_val = int(chat_id) | |
| except ValueError: | |
| chat_id_val = chat_id | |
| msg = await tg_client_manager.get_message(int(msg_id), chat_id=chat_id_val) | |
| media = msg.video or msg.document or msg.audio | |
| file_name = getattr(media, "file_name", "video.mp4") or "video.mp4" | |
| file_size = media.file_size | |
| stream_url = f"{get_addon_url(request)}/stream/file/{chat_id}/{msg_id}/{urllib.parse.quote(file_name)}{query_param}" | |
| subtitles = await find_subtitles_for_video(file_name, request=request, api_key=api_key) | |
| caption = getattr(msg, "caption", "") or "" | |
| lang_label = parse_audio_languages(file_name, caption=caption, default="Direct stream") | |
| streams.append({ | |
| "name": "▶ Telegram", | |
| "title": f"{file_name}\n💾 {lang_label} | 📦 {format_size(file_size)}", | |
| "url": stream_url, | |
| "subtitles": subtitles, | |
| "behaviorHints": { | |
| "notWebReady": True, | |
| } | |
| }) | |
| except Exception as e: | |
| logger.error(f"Failed resolving direct stream for {stream_id}: {e}") | |
| elif stream_id.startswith("tt"): | |
| imdb_id = stream_id | |
| season = None | |
| episode = None | |
| if ":" in stream_id: | |
| parts = stream_id.split(":") | |
| imdb_id = parts[0] | |
| season = int(parts[1]) | |
| episode = int(parts[2]) | |
| try: | |
| meta = await get_metadata_from_cinemeta(type, imdb_id) | |
| movie_name = meta.get("name") | |
| year_str = meta.get("year") | |
| year = None | |
| if year_str: | |
| try: | |
| year = int(str(year_str).split("-")[0]) | |
| except Exception: | |
| pass | |
| if movie_name: | |
| matcher = TelegramSearchMatcher() | |
| if type == "series" and season is not None and episode is not None: | |
| queries = matcher.build_series_queries(movie_name, season, episode)[:4] | |
| else: | |
| queries = matcher.build_movie_queries(movie_name, year)[:3] | |
| logger.info(f"Resolved IMDb {imdb_id} to '{movie_name}'. Searching Telegram with {len(queries)} focused queries...") | |
| search_tasks = [tg_client_manager.search_messages(query=q, limit=40) for q in queries] | |
| search_results_lists = await asyncio.gather(*search_tasks, return_exceptions=True) | |
| all_messages = {} | |
| tg_results_flat = [] | |
| for res_list in search_results_lists: | |
| if isinstance(res_list, list): | |
| for msg in res_list: | |
| if msg and (msg.chat.id, msg.id) not in all_messages: | |
| all_messages[(msg.chat.id, msg.id)] = msg | |
| tg_results_flat.append(msg) | |
| grouped_results = group_tg_messages(tg_results_flat) | |
| valid_streams = [] | |
| for item in grouped_results: | |
| if isinstance(item, tuple): | |
| base_name, parts = item | |
| first_msg = parts[0] | |
| media = first_msg.video or first_msg.document or first_msg.audio | |
| file_name = getattr(media, "file_name", "") or "" | |
| caption = first_msg.caption or "" | |
| score = matcher.score( | |
| file_name=base_name, | |
| caption=caption, | |
| title=movie_name, | |
| year=year, | |
| season=season, | |
| episode=episode | |
| ) | |
| if score < SCORE_THRESHOLD: | |
| continue | |
| total_size = sum((x.video or x.document or x.audio).file_size for x in parts if (x.video or x.document or x.audio)) | |
| msg_ids = ",".join(str(x.id) for x in parts) | |
| chat_id = first_msg.chat.id | |
| quality_str = parse_quality(f"{base_name} {caption}") | |
| is_zip = False | |
| if base_name.lower().endswith(".zip"): | |
| try: | |
| entries = await list_zip_files(tg_client_manager.client, parts) | |
| video_entries = [e for e in entries if is_video_file(e.filename)] | |
| if video_entries: | |
| is_zip = True | |
| for entry in video_entries: | |
| entry_score = matcher.score( | |
| file_name=entry.filename, | |
| caption="", | |
| title=movie_name, | |
| year=year, | |
| season=season, | |
| episode=episode | |
| ) | |
| if entry_score < SCORE_THRESHOLD: | |
| continue | |
| stream_url = f"{get_addon_url(request)}/stream/zip/{chat_id}/{msg_ids}/{urllib.parse.quote(entry.filename)}{query_param}" | |
| subtitles = await find_subtitles_for_video(entry.filename, request=request, api_key=api_key, cached_messages=tg_results_flat) | |
| lang_label = parse_audio_languages(entry.filename, title=movie_name, default="Stream ZIP entry") | |
| valid_streams.append({ | |
| "name": f"▶ Telegram ZIP {quality_str}", | |
| "title": f"{entry.filename}\n💾 {lang_label} | 📦 {format_size(entry.file_size)}", | |
| "url": stream_url, | |
| "subtitles": subtitles, | |
| "behaviorHints": {"notWebReady": True}, | |
| "_quality": quality_tier(quality_str), | |
| "_size": entry.file_size, | |
| "_score": entry_score, | |
| "_raw_name": entry.filename, | |
| "_clean_name": normalize_release_name(entry.filename), | |
| "_unique_id": None | |
| }) | |
| except Exception as e: | |
| logger.error(f"Error checking split ZIP for IMDB: {e}") | |
| if not is_zip: | |
| if hide_split_streams and not base_name.lower().endswith(".zip"): | |
| logger.info(f"Hiding split video stream '{base_name}' for {imdb_id} (HIDE_SPLIT_STREAMS enabled)") | |
| continue | |
| if not is_video_file(base_name): | |
| continue | |
| first_media = first_msg.video or first_msg.document or first_msg.audio | |
| first_uid = getattr(first_media, "file_unique_id", None) if first_media else None | |
| stream_url = f"{get_addon_url(request)}/stream/split/{chat_id}/{msg_ids}/{urllib.parse.quote(base_name)}{query_param}" | |
| first_caption = getattr(first_msg, "caption", "") or "" | |
| lang_label = parse_audio_languages(base_name, caption=first_caption, title=movie_name, default="Stitch stream") | |
| valid_streams.append({ | |
| "name": f"▶ Telegram Split {quality_str}", | |
| "title": f"{base_name}\n💾 {lang_label} | 📦 {format_size(total_size)}", | |
| "url": stream_url, | |
| "behaviorHints": {"notWebReady": True}, | |
| "_quality": quality_tier(quality_str), | |
| "_size": total_size, | |
| "_score": score, | |
| "_raw_name": base_name, | |
| "_clean_name": normalize_release_name(base_name), | |
| "_unique_id": first_uid | |
| }) | |
| else: | |
| msg = item | |
| media = msg.video or msg.document or msg.audio | |
| file_name = getattr(media, "file_name", None) or msg.caption or "" | |
| caption = msg.caption or "" | |
| score = matcher.score( | |
| file_name=file_name, | |
| caption=caption, | |
| title=movie_name, | |
| year=year, | |
| season=season, | |
| episode=episode | |
| ) | |
| if score < SCORE_THRESHOLD: | |
| continue | |
| file_size = media.file_size | |
| chat_id = msg.chat.id | |
| quality_str = parse_quality(f"{file_name} {caption}") | |
| uid = getattr(media, "file_unique_id", None) | |
| is_zip = False | |
| if file_name.lower().endswith(".zip"): | |
| try: | |
| entries = await list_zip_files(tg_client_manager.client, msg) | |
| video_entries = [e for e in entries if is_video_file(e.filename)] | |
| if video_entries: | |
| is_zip = True | |
| for entry in video_entries: | |
| entry_score = matcher.score( | |
| file_name=entry.filename, | |
| caption="", | |
| title=movie_name, | |
| year=year, | |
| season=season, | |
| episode=episode | |
| ) | |
| if entry_score < SCORE_THRESHOLD: | |
| continue | |
| stream_url = f"{get_addon_url(request)}/stream/zip/{chat_id}/{msg.id}/{urllib.parse.quote(entry.filename)}{query_param}" | |
| subtitles = await find_subtitles_for_video(entry.filename, request=request, api_key=api_key, cached_messages=tg_results_flat) | |
| lang_label = parse_audio_languages(entry.filename, title=movie_name, default="Stream ZIP entry") | |
| valid_streams.append({ | |
| "name": f"▶ Telegram ZIP {quality_str}", | |
| "title": f"{entry.filename}\n💾 {lang_label} | 📦 {format_size(entry.file_size)}", | |
| "url": stream_url, | |
| "subtitles": subtitles, | |
| "behaviorHints": {"notWebReady": True}, | |
| "_quality": quality_tier(quality_str), | |
| "_size": entry.file_size, | |
| "_score": entry_score, | |
| "_raw_name": entry.filename, | |
| "_clean_name": normalize_release_name(entry.filename), | |
| "_unique_id": None | |
| }) | |
| except Exception as e: | |
| logger.error(f"Error checking standalone ZIP for IMDB: {e}") | |
| if not is_zip: | |
| if not is_video_file(file_name): | |
| continue | |
| if hide_split_streams and parse_split_info(file_name)[0] is not None and not file_name.lower().endswith(".zip"): | |
| logger.info(f"Hiding individual split part stream '{file_name}' for {imdb_id} (HIDE_SPLIT_STREAMS enabled)") | |
| continue | |
| stream_url = f"{get_addon_url(request)}/stream/file/{chat_id}/{msg.id}/{urllib.parse.quote(file_name)}{query_param}" | |
| subtitles = await find_subtitles_for_video(file_name, request=request, api_key=api_key, cached_messages=tg_results_flat) | |
| lang_label = parse_audio_languages(file_name, caption=caption, title=movie_name, default="Telegram File") | |
| valid_streams.append({ | |
| "name": f"▶ Telegram {quality_str}", | |
| "title": f"{file_name}\n💾 {lang_label} | 📦 {format_size(file_size)}", | |
| "url": stream_url, | |
| "subtitles": subtitles, | |
| "behaviorHints": {"notWebReady": True}, | |
| "_quality": quality_tier(quality_str), | |
| "_size": file_size, | |
| "_score": score, | |
| "_raw_name": file_name, | |
| "_clean_name": normalize_release_name(file_name), | |
| "_unique_id": uid | |
| }) | |
| valid_streams.sort(key=lambda x: (x.get("_size", 0), x.get("_quality", 0), x.get("_score", 0)), reverse=True) | |
| seen_unique_ids = set() | |
| seen_names_and_sizes = set() | |
| seen_sizes = set() | |
| seen_titles = set() | |
| for s in valid_streams: | |
| uid = s.pop("_unique_id", None) | |
| size = s.pop("_size", 0) | |
| quality = s.pop("_quality", None) | |
| score = s.pop("_score", 0) | |
| clean_name = s.pop("_clean_name", "") | |
| raw_name = s.pop("_raw_name", "") | |
| # 1. Deduplicate by Telegram unique file ID (forwards / shared copies) | |
| if uid and uid in seen_unique_ids: | |
| continue | |
| # 2. Deduplicate by filename and exact file size | |
| if raw_name and (raw_name, size) in seen_names_and_sizes: | |
| continue | |
| if clean_name and (clean_name, size) in seen_names_and_sizes: | |
| continue | |
| # 3. Deduplicate by file size for large media files (> 10MB) matching the same IMDb title | |
| if size > 10 * 1024 * 1024 and size in seen_sizes: | |
| continue | |
| # 4. Deduplicate identical stream display name and title | |
| stream_key = (s.get("name", ""), s.get("title", "")) | |
| if stream_key in seen_titles: | |
| continue | |
| if uid: | |
| seen_unique_ids.add(uid) | |
| if raw_name: | |
| seen_names_and_sizes.add((raw_name, size)) | |
| if clean_name: | |
| seen_names_and_sizes.add((clean_name, size)) | |
| if size > 10 * 1024 * 1024: | |
| seen_sizes.add(size) | |
| seen_titles.add(stream_key) | |
| streams.append(s) | |
| except Exception as e: | |
| logger.error(f"Cinemeta search/resolve failed: {e}") | |
| response_data = { | |
| "streams": streams, | |
| "cacheMaxAge": 300, | |
| "staleRevalidate": 60 | |
| } | |
| _stream_cache[cache_key] = (now, response_data) | |
| return JSONResponse( | |
| content=response_data, | |
| headers={"Cache-Control": "public, max-age=300, stale-while-revalidate=60", "X-Cache": "MISS"} | |
| ) | |
| async def subtitles_handler( | |
| type: str, | |
| id: str, | |
| request: Request, | |
| extra: str = None, | |
| api_key: str = "" | |
| ): | |
| if Config.API_KEY: | |
| actual_key = api_key or request.query_params.get("api_key", "") | |
| if actual_key != Config.API_KEY: | |
| raise HTTPException(status_code=403, detail="Unauthorized") | |
| subtitles = [] | |
| if id.startswith("tgfile_"): | |
| parts = id.split("_") | |
| if len(parts) >= 3: | |
| chat_id = parts[1] | |
| msg_id = parts[2] | |
| try: | |
| try: | |
| chat_id_val = int(chat_id) | |
| except ValueError: | |
| chat_id_val = chat_id | |
| msg = await tg_client_manager.get_message(int(msg_id), chat_id=chat_id_val) | |
| media = msg.video or msg.document or msg.audio | |
| video_filename = getattr(media, "file_name", "") or "" | |
| if video_filename: | |
| subtitles = await find_subtitles_for_video(video_filename, request=request, api_key=api_key) | |
| except Exception as e: | |
| logger.error(f"Failed to resolve subtitles for direct catalog ID {id}: {e}") | |
| elif id.startswith("tt"): | |
| imdb_id = id | |
| season = None | |
| episode = None | |
| if ":" in id: | |
| parts = id.split(":") | |
| imdb_id = parts[0] | |
| season = int(parts[1]) | |
| episode = int(parts[2]) | |
| try: | |
| video_filename = None | |
| if extra: | |
| decoded_extra = urllib.parse.unquote(extra) | |
| if "?" in decoded_extra: | |
| decoded_extra = decoded_extra.split("?", 1)[0] | |
| params = urllib.parse.parse_qs(decoded_extra) | |
| if "filename" in params: | |
| video_filename = params["filename"][0] | |
| if video_filename: | |
| logger.info(f"Resolving subtitles directly for filename: '{video_filename}'") | |
| subtitles = await find_subtitles_for_video(video_filename, request=request, api_key=api_key) | |
| else: | |
| meta = await get_metadata_from_cinemeta(type, imdb_id) | |
| movie_name = meta.get("name") | |
| if movie_name: | |
| tg_results = await tg_client_manager.search_messages(query=movie_name, limit=50) | |
| for msg in tg_results: | |
| media = msg.video or msg.document or msg.audio | |
| fn = getattr(media, "file_name", "") or msg.caption or "" | |
| if type == "series" and not matches_episode(fn, season, episode): | |
| continue | |
| video_filename = fn | |
| break | |
| if video_filename: | |
| subtitles = await find_subtitles_for_video(video_filename, request=request, api_key=api_key, cached_messages=tg_results) | |
| except Exception as e: | |
| logger.error(f"Failed to resolve subtitles for IMDb ID {id}: {e}") | |
| return {"subtitles": subtitles} | |
| async def tg_subtitle_proxy( | |
| chat_id: str, | |
| message_id: int, | |
| filename: str, | |
| request: Request, | |
| api_key: str = "" | |
| ): | |
| if Config.API_KEY and api_key != Config.API_KEY: | |
| raise HTTPException(status_code=403, detail="Unauthorized") | |
| try: | |
| try: | |
| chat_id_val = int(chat_id) | |
| except ValueError: | |
| chat_id_val = chat_id | |
| msg = await tg_client_manager.get_message(message_id, chat_id=chat_id_val) | |
| except Exception as e: | |
| logger.error(f"Proxy failed to fetch subtitle message: {e}") | |
| raise HTTPException(status_code=404, detail="Subtitle file not found") | |
| if not msg: | |
| raise HTTPException(status_code=404, detail="Subtitle message not found") | |
| media = msg.document or msg.audio or msg.video | |
| if not media: | |
| raise HTTPException(status_code=404, detail="No media found in subtitle message") | |
| content_type = "text/plain" | |
| filename_lower = filename.lower() | |
| if filename_lower.endswith(".srt"): | |
| content_type = "application/x-subrip" | |
| elif filename_lower.endswith(".vtt"): | |
| content_type = "text/vtt" | |
| elif filename_lower.endswith(".ass"): | |
| content_type = "text/plain" | |
| headers = { | |
| "Content-Disposition": format_content_disposition(filename), | |
| "Access-Control-Allow-Origin": "*", | |
| "Content-Length": str(media.file_size), | |
| } | |
| if request.method == "HEAD": | |
| return Response( | |
| status_code=200, | |
| media_type=content_type, | |
| headers=headers | |
| ) | |
| try: | |
| logger.info(f"Downloading subtitle file from Telegram: {filename} (msg ID {message_id})") | |
| file_buffer = await tg_client_manager.client.download_media(msg, in_memory=True) | |
| content = file_buffer.getvalue() | |
| except Exception as e: | |
| logger.error(f"Failed to download subtitle file: {e}") | |
| raise HTTPException(status_code=500, detail="Failed to retrieve subtitle media") | |
| return Response( | |
| content=content, | |
| media_type=content_type, | |
| headers=headers | |
| ) | |
| async def tg_stream_proxy( | |
| chat_id: str, | |
| message_id: int, | |
| filename: str, | |
| request: Request, | |
| api_key: str = "" | |
| ): | |
| if Config.API_KEY and api_key != Config.API_KEY: | |
| raise HTTPException(status_code=403, detail="Unauthorized") | |
| try: | |
| try: | |
| chat_id_val = int(chat_id) | |
| except ValueError: | |
| chat_id_val = chat_id | |
| msg = await tg_client_manager.get_message(message_id, chat_id=chat_id_val) | |
| except Exception as e: | |
| logger.error(f"Proxy failed to fetch message: {e}") | |
| raise HTTPException(status_code=404, detail="Media file not found") | |
| if not msg: | |
| raise HTTPException(status_code=404, detail="Media message not found") | |
| media = msg.video or msg.document or msg.audio | |
| if not media: | |
| raise HTTPException(status_code=404, detail="No playable media found in message") | |
| file_size = media.file_size | |
| mime_type = get_mime_type(filename, media.mime_type) | |
| range_header = request.headers.get("Range") | |
| if request.method == "GET": | |
| asyncio.create_task( | |
| tg_client_manager.send_play_log(filename, chat_id_val, message_id) | |
| ) | |
| start = 0 | |
| end = file_size - 1 | |
| if range_header: | |
| try: | |
| bytes_range = range_header.replace("bytes=", "").split("-") | |
| if bytes_range[0]: | |
| start = int(bytes_range[0]) | |
| if len(bytes_range) > 1 and bytes_range[1]: | |
| end = int(bytes_range[1]) | |
| except ValueError: | |
| pass | |
| content_length = end - start + 1 | |
| chunk_size = 1024 * 1024 # 1 MB | |
| offset = start // chunk_size | |
| skip_bytes = start % chunk_size | |
| status_code = 206 if range_header else 200 | |
| headers = { | |
| "Accept-Ranges": "bytes", | |
| "Content-Length": str(content_length), | |
| "Content-Disposition": format_content_disposition(filename), | |
| "Connection": "keep-alive", | |
| "Cache-Control": "no-cache", | |
| "X-Accel-Buffering": "no", | |
| } | |
| # Content-Range MUST only be sent with 206 Partial Content, not 200. | |
| # Sending it on 200 confuses video players and causes playback failures. | |
| if range_header: | |
| headers["Content-Range"] = f"bytes {start}-{end}/{file_size}" | |
| if request.method == "HEAD": | |
| logger.info(f"HEAD request for media '{filename}' (bytes {start}-{end}/{file_size}) - Status {status_code}") | |
| return Response( | |
| status_code=status_code, | |
| media_type=mime_type, | |
| headers=headers | |
| ) | |
| async def file_generator(): | |
| nonlocal msg | |
| bytes_sent = 0 | |
| bytes_to_skip = skip_bytes | |
| retries = 0 | |
| max_retries = 2 | |
| current_offset = offset | |
| while retries <= max_retries: | |
| try: | |
| async for chunk in tg_client_manager.client.stream_media(msg, offset=current_offset): | |
| if bytes_to_skip > 0: | |
| if bytes_to_skip < len(chunk): | |
| chunk = chunk[bytes_to_skip:] | |
| bytes_to_skip = 0 | |
| else: | |
| bytes_to_skip -= len(chunk) | |
| continue | |
| if bytes_sent + len(chunk) > content_length: | |
| chunk = chunk[:content_length - bytes_sent] | |
| yield chunk | |
| bytes_sent += len(chunk) | |
| if bytes_sent >= content_length: | |
| break | |
| # Completed successfully | |
| break | |
| except asyncio.CancelledError: | |
| logger.info(f"Stream cancelled by client for message {message_id}") | |
| break | |
| except Exception as e: | |
| err_name = type(e).__name__ | |
| if "FileReferenceExpired" in err_name or "FILE_REFERENCE" in str(e).upper(): | |
| retries += 1 | |
| if retries <= max_retries: | |
| logger.warning(f"File reference expired for msg {message_id}, refreshing (retry {retries}/{max_retries})...") | |
| try: | |
| msg = await tg_client_manager.client.get_messages(chat_id=chat_id_val, message_ids=message_id) | |
| # Calculate new offset based on bytes already sent | |
| current_offset = (start + bytes_sent) // chunk_size | |
| bytes_to_skip = (start + bytes_sent) % chunk_size | |
| continue | |
| except Exception as re_err: | |
| logger.error(f"Failed to refresh message: {re_err}") | |
| break | |
| else: | |
| logger.error(f"Max retries exceeded for file reference on msg {message_id}") | |
| break | |
| elif "FloodWait" in err_name or "FLOOD_WAIT" in str(e).upper(): | |
| wait_sec = getattr(e, "value", None) or getattr(e, "x", "several") | |
| logger.error( | |
| f"🚨 [FLOOD WAIT] Telegram rate-limited media stream for '{filename}'! " | |
| f"Required wait: {wait_sec}s. Stream stalled." | |
| ) | |
| break | |
| else: | |
| logger.error(f"Streaming error on message {message_id}: {e}") | |
| break | |
| logger.info(f"Streaming media '{filename}' (bytes {start}-{end}/{file_size}) - Status {status_code}") | |
| return StreamingResponse( | |
| file_generator(), | |
| status_code=status_code, | |
| media_type=mime_type, | |
| headers=headers | |
| ) | |
| async def tg_split_stream_proxy( | |
| chat_id: str, | |
| message_ids: str, | |
| filename: str, | |
| request: Request, | |
| api_key: str = "" | |
| ): | |
| if Config.API_KEY and api_key != Config.API_KEY: | |
| raise HTTPException(status_code=403, detail="Unauthorized") | |
| msg_id_list = [int(x) for x in message_ids.split(",") if x.strip().isdigit()] | |
| if not msg_id_list: | |
| raise HTTPException(status_code=400, detail="Invalid message IDs") | |
| try: | |
| chat_id_val = int(chat_id) | |
| except ValueError: | |
| chat_id_val = chat_id | |
| if request.method == "GET": | |
| asyncio.create_task( | |
| tg_client_manager.send_play_log(filename, chat_id_val, msg_id_list[0]) | |
| ) | |
| split_cache_key = (str(chat_id_val), tuple(msg_id_list)) | |
| now = time.time() | |
| if split_cache_key in _split_metadata_cache and now - _split_metadata_cache[split_cache_key]["time"] < 3600: | |
| cached_info = _split_metadata_cache[split_cache_key] | |
| chunks_info = cached_info["chunks_info"] | |
| total_size = cached_info["total_size"] | |
| else: | |
| chunks_info = [] | |
| total_size = 0 | |
| try: | |
| fetched_msgs = await tg_client_manager.get_messages(msg_id_list, chat_id=chat_id_val) | |
| for msg in fetched_msgs: | |
| media = msg.video or msg.document or msg.audio | |
| if not media: | |
| continue | |
| chunks_info.append({ | |
| "msg": msg, | |
| "media": media, | |
| "size": media.file_size, | |
| "start_byte": total_size, | |
| "end_byte": total_size + media.file_size - 1 | |
| }) | |
| total_size += media.file_size | |
| if not chunks_info: | |
| raise HTTPException(status_code=404, detail="No playable media found in split parts") | |
| _split_metadata_cache[split_cache_key] = { | |
| "time": now, | |
| "chunks_info": chunks_info, | |
| "total_size": total_size | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error fetching metadata for split parts {message_ids}: {e}") | |
| raise HTTPException(status_code=500, detail="Failed resolving split file metadata") | |
| range_header = request.headers.get("Range") | |
| start = 0 | |
| end = total_size - 1 | |
| if range_header: | |
| try: | |
| bytes_range = range_header.replace("bytes=", "").split("-") | |
| if bytes_range[0]: | |
| start = int(bytes_range[0]) | |
| if len(bytes_range) > 1 and bytes_range[1]: | |
| end = int(bytes_range[1]) | |
| except ValueError: | |
| pass | |
| content_length = end - start + 1 | |
| mime_type = get_mime_type(filename, chunks_info[0]["media"].mime_type) | |
| status_code = 206 if range_header else 200 | |
| headers = { | |
| "Accept-Ranges": "bytes", | |
| "Content-Length": str(content_length), | |
| "Content-Disposition": format_content_disposition(filename), | |
| "Connection": "keep-alive", | |
| "Cache-Control": "no-cache", | |
| "X-Accel-Buffering": "no", | |
| } | |
| if range_header: | |
| headers["Content-Range"] = f"bytes {start}-{end}/{total_size}" | |
| if request.method == "HEAD": | |
| return Response( | |
| status_code=status_code, | |
| media_type=mime_type, | |
| headers=headers | |
| ) | |
| async def split_file_generator(): | |
| bytes_sent = 0 | |
| block_size = 1024 * 1024 # 1 MB blocks matching Pyrogram chunk size | |
| for chunk in chunks_info: | |
| c_start = chunk["start_byte"] | |
| c_end = chunk["end_byte"] | |
| if c_end < start or c_start > end: | |
| continue | |
| read_start = max(c_start, start) | |
| read_end = min(c_end, end) | |
| chunk_read_len = read_end - read_start + 1 | |
| local_offset = read_start - c_start | |
| offset_blocks = local_offset // block_size | |
| skip_bytes = local_offset % block_size | |
| chunk_bytes_sent = 0 | |
| bytes_to_skip = skip_bytes | |
| try: | |
| async for block in tg_client_manager.client.stream_media(chunk["msg"], offset=offset_blocks): | |
| if bytes_to_skip > 0: | |
| if bytes_to_skip < len(block): | |
| block = block[bytes_to_skip:] | |
| bytes_to_skip = 0 | |
| else: | |
| bytes_to_skip -= len(block) | |
| continue | |
| if chunk_bytes_sent + len(block) > chunk_read_len: | |
| block = block[:chunk_read_len - chunk_bytes_sent] | |
| yield block | |
| chunk_bytes_sent += len(block) | |
| bytes_sent += len(block) | |
| if chunk_bytes_sent >= chunk_read_len: | |
| break | |
| except Exception as e: | |
| err_name = type(e).__name__ | |
| if "FloodWait" in err_name or "FLOOD_WAIT" in str(e).upper(): | |
| wait_sec = getattr(e, "value", None) or getattr(e, "x", "several") | |
| logger.error( | |
| f"🚨 [FLOOD WAIT] Telegram rate-limited split media stream for '{filename}'! " | |
| f"Required wait: {wait_sec}s. Stream stalled." | |
| ) | |
| else: | |
| logger.error(f"Error streaming split chunk: {e}") | |
| break | |
| if bytes_sent >= content_length: | |
| break | |
| logger.info(f"Streaming split media '{filename}' (bytes {start}-{end}/{total_size}) - Status {status_code}") | |
| return StreamingResponse( | |
| split_file_generator(), | |
| status_code=status_code, | |
| media_type=mime_type, | |
| headers=headers | |
| ) | |
| async def tg_zip_stream_proxy( | |
| chat_id: str, | |
| message_ids: str, | |
| filename: str, | |
| request: Request, | |
| api_key: str = "" | |
| ): | |
| if Config.API_KEY and api_key != Config.API_KEY: | |
| raise HTTPException(status_code=403, detail="Unauthorized") | |
| msg_id_list = [int(x) for x in message_ids.split(",") if x.strip().isdigit()] | |
| if not msg_id_list: | |
| raise HTTPException(status_code=400, detail="Invalid message IDs") | |
| try: | |
| chat_id_val = int(chat_id) | |
| except ValueError: | |
| chat_id_val = chat_id | |
| if request.method == "GET": | |
| asyncio.create_task( | |
| tg_client_manager.send_play_log(filename, chat_id_val, msg_id_list[0]) | |
| ) | |
| messages = [] | |
| for msg_id in msg_id_list: | |
| # Fetch fresh messages for valid file_reference | |
| try: | |
| msg = await tg_client_manager.client.get_messages(chat_id=chat_id_val, message_ids=msg_id) | |
| except Exception: | |
| msg = await tg_client_manager.get_message(msg_id, chat_id=chat_id_val) | |
| if msg: | |
| messages.append(msg) | |
| if not messages: | |
| raise HTTPException(status_code=404, detail="Messages not found") | |
| reader = TelegramSeekableReader(tg_client_manager.client, messages) | |
| zip_entries = await list_zip_files(tg_client_manager.client, messages) | |
| target_entry = None | |
| if zip_entries: | |
| # 1. Exact match or URL-unquoted match | |
| unquoted_req = urllib.parse.unquote(filename) | |
| for entry in zip_entries: | |
| if entry.filename == filename or urllib.parse.unquote(entry.filename) == unquoted_req: | |
| target_entry = entry | |
| break | |
| # 2. Basename match (case-insensitive) | |
| if not target_entry: | |
| fn_base = os.path.basename(unquoted_req).lower() | |
| for entry in zip_entries: | |
| if os.path.basename(entry.filename).lower() == fn_base: | |
| target_entry = entry | |
| break | |
| # 3. Normalized path match | |
| if not target_entry: | |
| fn_norm = unquoted_req.strip("/").lower() | |
| for entry in zip_entries: | |
| if entry.filename.strip("/").lower() == fn_norm: | |
| target_entry = entry | |
| break | |
| # 4. If only one video entry in the archive, pick it | |
| if not target_entry: | |
| video_entries = [e for e in zip_entries if is_video_file(e.filename)] | |
| if len(video_entries) == 1: | |
| target_entry = video_entries[0] | |
| elif len(video_entries) > 1: | |
| target_entry = max(video_entries, key=lambda e: getattr(e, "file_size", 0)) | |
| # 5. Fallback to single entry | |
| if not target_entry and len(zip_entries) == 1: | |
| target_entry = zip_entries[0] | |
| # Teleflix-style fallback: If central directory failed and zip_entries is empty, | |
| # inspect the first block directly for a STORED (uncompressed) stream | |
| if not target_entry: | |
| first_block = await reader.fetch_block(0, 0) | |
| local_entries = parse_zip_local_headers(first_block, total_size=reader.total_size) | |
| if local_entries: | |
| target_entry = local_entries[0] | |
| if not target_entry: | |
| raise HTTPException(status_code=404, detail=f"File '{filename}' not found in ZIP archive") | |
| file_size = getattr(target_entry, "file_size", 0) | |
| data_start = 0 | |
| import zipfile | |
| if target_entry.compress_type == zipfile.ZIP_STORED: | |
| data_start = await get_zip_entry_data_offset(reader, getattr(target_entry, "header_offset", 0)) | |
| if file_size <= 0 or file_size == 0xFFFFFFFF: | |
| file_size = max(0, reader.total_size - data_start) | |
| mime_type = "video/mp4" | |
| filename_lower = filename.lower() | |
| if filename_lower.endswith(".mkv"): | |
| mime_type = "video/x-matroska" | |
| elif filename_lower.endswith(".mp4"): | |
| mime_type = "video/mp4" | |
| elif filename_lower.endswith(".avi"): | |
| mime_type = "video/x-msvideo" | |
| range_header = request.headers.get("Range") | |
| start = 0 | |
| end = file_size - 1 | |
| if range_header: | |
| try: | |
| bytes_range = range_header.replace("bytes=", "").split("-") | |
| if bytes_range[0]: | |
| start = int(bytes_range[0]) | |
| if len(bytes_range) > 1 and bytes_range[1]: | |
| end = int(bytes_range[1]) | |
| except ValueError: | |
| pass | |
| content_length = end - start + 1 | |
| status_code = 206 if range_header else 200 | |
| headers = { | |
| "Accept-Ranges": "bytes", | |
| "Content-Length": str(content_length), | |
| "Content-Disposition": format_content_disposition(filename), | |
| "Connection": "keep-alive", | |
| "Cache-Control": "no-cache", | |
| "X-Accel-Buffering": "no", | |
| } | |
| if range_header: | |
| headers["Content-Range"] = f"bytes {start}-{end}/{file_size}" | |
| if request.method == "HEAD": | |
| return Response( | |
| status_code=status_code, | |
| media_type=mime_type, | |
| headers=headers | |
| ) | |
| if target_entry.compress_type == zipfile.ZIP_STORED: | |
| logger.info(f"ZIP entry '{filename}' is STORED (uncompressed). Using direct offset proxy.") | |
| stream_start = data_start + start | |
| stream_end = data_start + end | |
| stream_len = stream_end - stream_start + 1 | |
| chunks_info = [] | |
| total_size = 0 | |
| for part in reader.parts: | |
| chunks_info.append({ | |
| "msg": part["message"], | |
| "media": part["media"], | |
| "size": part["size"], | |
| "start_byte": part["start"], | |
| "end_byte": part["end"] - 1 | |
| }) | |
| total_size += part["size"] | |
| async def split_file_generator(): | |
| bytes_sent = 0 | |
| block_size = 1024 * 1024 | |
| for chunk in chunks_info: | |
| c_start = chunk["start_byte"] | |
| c_end = chunk["end_byte"] | |
| if c_end < stream_start or c_start > stream_end: | |
| continue | |
| read_start = max(c_start, stream_start) | |
| read_end = min(c_end, stream_end) | |
| chunk_read_len = read_end - read_start + 1 | |
| local_offset = read_start - c_start | |
| offset_blocks = local_offset // block_size | |
| skip_bytes = local_offset % block_size | |
| chunk_bytes_sent = 0 | |
| bytes_to_skip = skip_bytes | |
| try: | |
| async for block in tg_client_manager.client.stream_media(chunk["msg"], offset=offset_blocks): | |
| if bytes_to_skip > 0: | |
| if bytes_to_skip < len(block): | |
| block = block[bytes_to_skip:] | |
| bytes_to_skip = 0 | |
| else: | |
| bytes_to_skip -= len(block) | |
| continue | |
| if chunk_bytes_sent + len(block) > chunk_read_len: | |
| block = block[:chunk_read_len - chunk_bytes_sent] | |
| yield block | |
| chunk_bytes_sent += len(block) | |
| bytes_sent += len(block) | |
| if chunk_bytes_sent >= chunk_read_len: | |
| break | |
| except asyncio.CancelledError: | |
| logger.info(f"Stream cancelled by client for zip media '{filename}'") | |
| break | |
| except Exception as e: | |
| err_name = type(e).__name__ | |
| if "FloodWait" in err_name or "FLOOD_WAIT" in str(e).upper(): | |
| wait_sec = getattr(e, "value", None) or getattr(e, "x", "several") | |
| logger.error( | |
| f"🚨 [FLOOD WAIT] Telegram rate-limited ZIP media stream for '{filename}'! " | |
| f"Required wait: {wait_sec}s. Stream stalled." | |
| ) | |
| else: | |
| logger.error(f"Error streaming split ZIP chunk: {e}") | |
| break | |
| if bytes_sent >= stream_len: | |
| break | |
| logger.info(f"Streaming uncompressed ZIP entry '{filename}' (raw bytes {stream_start}-{stream_end}/{total_size}) - Status {status_code}") | |
| return StreamingResponse( | |
| split_file_generator(), | |
| status_code=status_code, | |
| media_type=mime_type, | |
| headers=headers | |
| ) | |
| else: | |
| logger.info(f"ZIP entry '{filename}' is COMPRESSED (type {target_entry.compress_type}). Streaming on-the-fly decompression.") | |
| return StreamingResponse( | |
| zip_compressed_generator(reader, target_entry.filename, start, end), | |
| status_code=status_code, | |
| media_type=mime_type, | |
| headers=headers | |
| ) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("addon:app", host="0.0.0.0", port=Config.PORT, reload=True, timeout_keep_alive=300) | |