Stremio / addon.py
deepu2135
Revert "perf(streaming): parallelize first-chunk prefetch and increase bandwidth"
7e23c0d
Raw History Blame Contribute Delete
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")
@asynccontextmanager
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 = {}
@app.middleware("http")
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)
@app.middleware("http")
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
}
}
@app.api_route("/", methods=["GET", "HEAD"], response_class=HTMLResponse)
@app.api_route("/configure", methods=["GET", "HEAD"], response_class=HTMLResponse)
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)
@app.api_route("/manifest.json", methods=["GET", "HEAD"])
@app.api_route("/{api_key}/manifest.json", methods=["GET", "HEAD"])
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)
@app.get("/catalog/{type}/{catalog_id}.json", dependencies=[Depends(verify_api_key)])
@app.get("/catalog/{type}/{catalog_id}/{extra}.json", dependencies=[Depends(verify_api_key)])
@app.get("/{api_key}/catalog/{type}/{catalog_id}.json", dependencies=[Depends(verify_api_key)])
@app.get("/{api_key}/catalog/{type}/{catalog_id}/{extra}.json", dependencies=[Depends(verify_api_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
@app.get("/stremio_telegram_logo.png")
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")
@app.get("/stremio_telegram_banner.png")
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 = {}
@app.get("/poster/{chat_id}/{msg_id}")
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)
@app.get("/meta/{type}/{meta_id}.json", dependencies=[Depends(verify_api_key)])
@app.get("/{api_key}/meta/{type}/{meta_id}.json", dependencies=[Depends(verify_api_key)])
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
@app.get("/stream/{type}/{stream_id}.json")
@app.get("/{api_key}/stream/{type}/{stream_id}.json")
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"}
)
@app.get("/subtitles/{type}/{id}.json")
@app.get("/subtitles/{type}/{id}/{extra}.json")
@app.get("/{api_key}/subtitles/{type}/{id}.json")
@app.get("/{api_key}/subtitles/{type}/{id}/{extra}.json")
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}
@app.api_route("/stream/subtitle/{chat_id}/{message_id}/{filename:path}", methods=["GET", "HEAD"])
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
)
@app.api_route("/stream/file/{chat_id}/{message_id}/{filename:path}", methods=["GET", "HEAD"])
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
)
@app.api_route("/stream/split/{chat_id}/{message_ids}/{filename:path}", methods=["GET", "HEAD"])
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
)
@app.api_route("/stream/zip/{chat_id}/{message_ids}/{filename:path}", methods=["GET", "HEAD"])
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)