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