Karan Adhikari commited on
V-3.4.0
Browse files- Backend/__init__.py +1 -1
- Backend/helper/imdb.py +85 -131
- Backend/helper/metadata.py +431 -698
- README.md +179 -175
- pyproject.toml +3 -1
- requirements.txt +2 -0
- uv.lock +137 -1
Backend/__init__.py
CHANGED
|
@@ -11,4 +11,4 @@ StartTime = time()
|
|
| 11 |
USE_DEFAULT_ID: str = None
|
| 12 |
db = Database()
|
| 13 |
|
| 14 |
-
__version__ = "3.
|
|
|
|
| 11 |
USE_DEFAULT_ID: str = None
|
| 12 |
db = Database()
|
| 13 |
|
| 14 |
+
__version__ = "3.4.0"
|
Backend/helper/imdb.py
CHANGED
|
@@ -1,174 +1,128 @@
|
|
| 1 |
-
import httpx
|
| 2 |
-
import re
|
| 3 |
import asyncio
|
|
|
|
| 4 |
from typing import Optional, Dict, Any, List
|
| 5 |
|
|
|
|
|
|
|
| 6 |
BASE_URL = "https://v3-cinemeta.strem.io"
|
| 7 |
|
| 8 |
_client: Optional[httpx.AsyncClient] = None
|
| 9 |
_client_lock = asyncio.Lock()
|
| 10 |
|
| 11 |
-
# Pre-compiled emoji / unicode-symbol strip pattern
|
| 12 |
_EMOJI_RE = re.compile(
|
| 13 |
"["
|
| 14 |
-
"\U0001F600-\U0001F64F"
|
| 15 |
-
"\U0001F300-\U0001F5FF"
|
| 16 |
-
"\U0001F680-\U0001F6FF"
|
| 17 |
-
"\U0001F700-\U0001FAFF"
|
| 18 |
-
"\U00002702-\U000027B0"
|
| 19 |
-
"\U000024C2-\U0001F251"
|
| 20 |
-
"\u2600-\u26FF"
|
| 21 |
-
"\u2700-\u27BF"
|
| 22 |
-
"\uFE00-\uFE0F"
|
| 23 |
-
"\U0001F1E0-\U0001F1FF"
|
| 24 |
"]+",
|
| 25 |
re.UNICODE,
|
| 26 |
)
|
| 27 |
|
| 28 |
-
|
| 29 |
async def _get_client() -> httpx.AsyncClient:
|
| 30 |
global _client
|
| 31 |
async with _client_lock:
|
| 32 |
if _client is None or _client.is_closed:
|
| 33 |
-
_client = httpx.AsyncClient(
|
| 34 |
-
timeout=15.0,
|
| 35 |
-
follow_redirects=True
|
| 36 |
-
)
|
| 37 |
return _client
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
def _clean_search_query(query: str) -> str:
|
| 41 |
-
"""
|
| 42 |
-
Strip emojis, decorative unicode symbols and non-essential punctuation
|
| 43 |
-
from a search query so Cinemeta can find the title reliably.
|
| 44 |
-
"""
|
| 45 |
q = _EMOJI_RE.sub(" ", query)
|
| 46 |
-
# Keep word chars, spaces, hyphens, apostrophes, ampersands, colons
|
| 47 |
q = re.sub(r"[^\w\s\-\'&:.]", " ", q)
|
| 48 |
-
|
| 49 |
-
return q
|
| 50 |
-
|
| 51 |
|
| 52 |
def extract_first_year(year_string) -> int:
|
| 53 |
if not year_string:
|
| 54 |
return 0
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
if year_match:
|
| 58 |
-
return int(year_match.group(1))
|
| 59 |
-
return 0
|
| 60 |
-
|
| 61 |
|
| 62 |
async def search_title(query: str, type: str) -> Optional[Dict[str, Any]]:
|
| 63 |
-
"""
|
| 64 |
-
Return the single top Cinemeta result (backward-compatible).
|
| 65 |
-
Uses cleaned query to handle emojis / special chars in titles.
|
| 66 |
-
"""
|
| 67 |
results = await search_title_multi(query=query, type=type, limit=1)
|
| 68 |
return results[0] if results else None
|
| 69 |
|
| 70 |
-
|
| 71 |
async def search_title_multi(query: str, type: str, limit: int = 8) -> List[Dict[str, Any]]:
|
| 72 |
-
"""
|
| 73 |
-
Return up to *limit* Cinemeta results for *query*.
|
| 74 |
-
The caller is responsible for picking the best match via fuzzy scoring.
|
| 75 |
-
"""
|
| 76 |
-
client = await _get_client()
|
| 77 |
-
cinemeta_type = "series" if type == "tvSeries" else type
|
| 78 |
-
|
| 79 |
-
# Clean the query before sending – emojis break the URL match
|
| 80 |
cleaned = _clean_search_query(query)
|
| 81 |
normalized = ".".join(cleaned.strip().lower().split())
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
continue
|
| 95 |
-
results.append({
|
| 96 |
-
"id": imdb_id,
|
| 97 |
-
"type": type,
|
| 98 |
-
"title": meta.get("name", ""),
|
| 99 |
-
"year": meta.get("releaseInfo", ""),
|
| 100 |
-
"poster": meta.get("poster", ""),
|
| 101 |
-
})
|
| 102 |
-
return results
|
| 103 |
-
except Exception:
|
| 104 |
-
return []
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
async def get_detail(imdb_id: str, media_type: str) -> Optional[Dict[str, Any]]:
|
| 108 |
-
client = await _get_client()
|
| 109 |
-
cinemeta_type = "series" if media_type in ["tvSeries", "tv"] else "movie"
|
| 110 |
-
|
| 111 |
-
try:
|
| 112 |
-
url = f"{BASE_URL}/meta/{cinemeta_type}/{imdb_id}.json"
|
| 113 |
-
resp = await client.get(url)
|
| 114 |
-
|
| 115 |
-
if resp.status_code != 200:
|
| 116 |
-
return None
|
| 117 |
-
|
| 118 |
-
data = resp.json()
|
| 119 |
-
meta = data.get("meta")
|
| 120 |
-
if not meta:
|
| 121 |
-
return None
|
| 122 |
-
|
| 123 |
-
year_value = 0
|
| 124 |
-
for field in ["year", "releaseInfo", "released"]:
|
| 125 |
-
if meta.get(field):
|
| 126 |
-
year_value = extract_first_year(meta[field])
|
| 127 |
-
if year_value:
|
| 128 |
-
break
|
| 129 |
-
|
| 130 |
-
return {
|
| 131 |
-
"id": meta.get("imdb_id") or meta.get("id"),
|
| 132 |
-
"moviedb_id": meta.get("moviedb_id"),
|
| 133 |
-
"type": meta.get("type", media_type),
|
| 134 |
"title": meta.get("name", ""),
|
| 135 |
-
"
|
| 136 |
-
"genre": meta.get("genres") or meta.get("genre", []),
|
| 137 |
-
"releaseDetailed": {"year": year_value},
|
| 138 |
-
"rating": {"star": float(meta.get("imdbRating", 0) or 0)},
|
| 139 |
"poster": meta.get("poster", ""),
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
"runtime": meta.get("runtime") or 0,
|
| 143 |
-
"director": meta.get("director", []),
|
| 144 |
-
"cast": meta.get("cast", []),
|
| 145 |
-
"videos": meta.get("videos", [])
|
| 146 |
-
}
|
| 147 |
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
| 149 |
return None
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
async def get_season(imdb_id: str, season_id: int, episode_id: int) -> Optional[Dict[str, Any]]:
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
'no': str(episode_id),
|
| 167 |
-
'season': str(season_id),
|
| 168 |
-
'image': video.get('thumbnail', ''),
|
| 169 |
-
'plot': video.get('overview', ''),
|
| 170 |
-
'released': video.get('released', '')
|
| 171 |
-
}
|
| 172 |
-
return None
|
| 173 |
-
except Exception:
|
| 174 |
-
return None
|
|
|
|
|
|
|
|
|
|
| 1 |
import asyncio
|
| 2 |
+
import re
|
| 3 |
from typing import Optional, Dict, Any, List
|
| 4 |
|
| 5 |
+
import httpx
|
| 6 |
+
|
| 7 |
BASE_URL = "https://v3-cinemeta.strem.io"
|
| 8 |
|
| 9 |
_client: Optional[httpx.AsyncClient] = None
|
| 10 |
_client_lock = asyncio.Lock()
|
| 11 |
|
|
|
|
| 12 |
_EMOJI_RE = re.compile(
|
| 13 |
"["
|
| 14 |
+
"\U0001F600-\U0001F64F"
|
| 15 |
+
"\U0001F300-\U0001F5FF"
|
| 16 |
+
"\U0001F680-\U0001F6FF"
|
| 17 |
+
"\U0001F700-\U0001FAFF"
|
| 18 |
+
"\U00002702-\U000027B0"
|
| 19 |
+
"\U000024C2-\U0001F251"
|
| 20 |
+
"\u2600-\u26FF"
|
| 21 |
+
"\u2700-\u27BF"
|
| 22 |
+
"\uFE00-\uFE0F"
|
| 23 |
+
"\U0001F1E0-\U0001F1FF"
|
| 24 |
"]+",
|
| 25 |
re.UNICODE,
|
| 26 |
)
|
| 27 |
|
|
|
|
| 28 |
async def _get_client() -> httpx.AsyncClient:
|
| 29 |
global _client
|
| 30 |
async with _client_lock:
|
| 31 |
if _client is None or _client.is_closed:
|
| 32 |
+
_client = httpx.AsyncClient(timeout=15.0, follow_redirects=True)
|
|
|
|
|
|
|
|
|
|
| 33 |
return _client
|
| 34 |
|
| 35 |
+
def _cinemeta_type(media_type: str) -> str:
|
| 36 |
+
return "series" if media_type in ("tvSeries", "tv", "series") else "movie"
|
| 37 |
+
|
| 38 |
+
async def _fetch_json(url: str) -> Optional[Dict[str, Any]]:
|
| 39 |
+
try:
|
| 40 |
+
client = await _get_client()
|
| 41 |
+
resp = await client.get(url)
|
| 42 |
+
if resp.status_code != 200:
|
| 43 |
+
return None
|
| 44 |
+
return resp.json()
|
| 45 |
+
except Exception:
|
| 46 |
+
return None
|
| 47 |
|
| 48 |
def _clean_search_query(query: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
q = _EMOJI_RE.sub(" ", query)
|
|
|
|
| 50 |
q = re.sub(r"[^\w\s\-\'&:.]", " ", q)
|
| 51 |
+
return re.sub(r"\s+", " ", q).strip()
|
|
|
|
|
|
|
| 52 |
|
| 53 |
def extract_first_year(year_string) -> int:
|
| 54 |
if not year_string:
|
| 55 |
return 0
|
| 56 |
+
m = re.search(r"(\d{4})", str(year_string))
|
| 57 |
+
return int(m.group(1)) if m else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
async def search_title(query: str, type: str) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
results = await search_title_multi(query=query, type=type, limit=1)
|
| 61 |
return results[0] if results else None
|
| 62 |
|
|
|
|
| 63 |
async def search_title_multi(query: str, type: str, limit: int = 8) -> List[Dict[str, Any]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
cleaned = _clean_search_query(query)
|
| 65 |
normalized = ".".join(cleaned.strip().lower().split())
|
| 66 |
+
url = f"{BASE_URL}/catalog/{_cinemeta_type(type)}/imdb/search={normalized}.json"
|
| 67 |
+
|
| 68 |
+
data = await _fetch_json(url)
|
| 69 |
+
metas = (data or {}).get("metas") or []
|
| 70 |
+
results: List[Dict[str, Any]] = []
|
| 71 |
+
for meta in metas[:limit]:
|
| 72 |
+
imdb_id = meta.get("imdb_id") or meta.get("id", "")
|
| 73 |
+
if not imdb_id:
|
| 74 |
+
continue
|
| 75 |
+
results.append({
|
| 76 |
+
"id": imdb_id,
|
| 77 |
+
"type": type,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
"title": meta.get("name", ""),
|
| 79 |
+
"year": meta.get("releaseInfo", ""),
|
|
|
|
|
|
|
|
|
|
| 80 |
"poster": meta.get("poster", ""),
|
| 81 |
+
})
|
| 82 |
+
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
+
async def get_detail(imdb_id: str, media_type: str) -> Optional[Dict[str, Any]]:
|
| 85 |
+
data = await _fetch_json(f"{BASE_URL}/meta/{_cinemeta_type(media_type)}/{imdb_id}.json")
|
| 86 |
+
meta = (data or {}).get("meta")
|
| 87 |
+
if not meta or not meta.get("name"):
|
| 88 |
return None
|
| 89 |
|
| 90 |
+
year_value = 0
|
| 91 |
+
for field in ("year", "releaseInfo", "released"):
|
| 92 |
+
if meta.get(field):
|
| 93 |
+
year_value = extract_first_year(meta[field])
|
| 94 |
+
if year_value:
|
| 95 |
+
break
|
| 96 |
+
|
| 97 |
+
return {
|
| 98 |
+
"id": meta.get("imdb_id") or meta.get("id"),
|
| 99 |
+
"moviedb_id": meta.get("moviedb_id"),
|
| 100 |
+
"type": meta.get("type") or media_type,
|
| 101 |
+
"title": meta.get("name") or "",
|
| 102 |
+
"plot": meta.get("description") or "",
|
| 103 |
+
"genre": meta.get("genres") or meta.get("genre") or [],
|
| 104 |
+
"releaseDetailed": {"year": year_value},
|
| 105 |
+
"rating": {"star": float(meta.get("imdbRating") or 0)},
|
| 106 |
+
"poster": meta.get("poster") or "",
|
| 107 |
+
"background": meta.get("background") or "",
|
| 108 |
+
"logo": meta.get("logo") or "",
|
| 109 |
+
"runtime": meta.get("runtime") or 0,
|
| 110 |
+
"director": meta.get("director") or [],
|
| 111 |
+
"cast": meta.get("cast") or [],
|
| 112 |
+
"videos": meta.get("videos") or [],
|
| 113 |
+
}
|
| 114 |
|
| 115 |
async def get_season(imdb_id: str, season_id: int, episode_id: int) -> Optional[Dict[str, Any]]:
|
| 116 |
+
data = await _fetch_json(f"{BASE_URL}/meta/series/{imdb_id}.json")
|
| 117 |
+
videos = (data or {}).get("meta", {}).get("videos") or []
|
| 118 |
+
for video in videos:
|
| 119 |
+
if str(video.get("season", "")) == str(season_id) and str(video.get("episode", "")) == str(episode_id):
|
| 120 |
+
return {
|
| 121 |
+
"title": video.get("title", f"Episode {episode_id}"),
|
| 122 |
+
"no": str(episode_id),
|
| 123 |
+
"season": str(season_id),
|
| 124 |
+
"image": video.get("thumbnail", ""),
|
| 125 |
+
"plot": video.get("overview", ""),
|
| 126 |
+
"released": video.get("released", ""),
|
| 127 |
+
}
|
| 128 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Backend/helper/metadata.py
CHANGED
|
@@ -1,78 +1,73 @@
|
|
| 1 |
import asyncio
|
| 2 |
-
import traceback
|
| 3 |
-
import PTN
|
| 4 |
import re
|
| 5 |
-
|
| 6 |
-
from difflib import SequenceMatcher
|
| 7 |
from typing import Optional, List
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
from Backend.helper.settings_manager import SettingsManager
|
| 12 |
import Backend
|
| 13 |
from Backend.logger import LOGGER
|
|
|
|
|
|
|
| 14 |
from Backend.helper.encrypt import encode_string
|
| 15 |
from Backend.helper.split_files import parse_split_info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
# ----------------- Configuration -----------------
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
_tmdb_client: aioTMDb | None = None
|
| 22 |
_tmdb_client_key: str | None = None
|
| 23 |
|
| 24 |
|
|
|
|
| 25 |
def get_tmdb_client() -> aioTMDb:
|
| 26 |
global _tmdb_client, _tmdb_client_key
|
| 27 |
-
|
| 28 |
current_key = SettingsManager.current().tmdb_api
|
| 29 |
if _tmdb_client is None or _tmdb_client_key != current_key:
|
| 30 |
_tmdb_client = aioTMDb(key=current_key, language="en-US", region="US")
|
| 31 |
_tmdb_client_key = current_key
|
| 32 |
-
|
| 33 |
return _tmdb_client
|
| 34 |
|
| 35 |
|
| 36 |
-
# Cache dictionaries (per run)
|
| 37 |
-
IMDB_CACHE: dict = {}
|
| 38 |
-
TMDB_SEARCH_CACHE: dict = {}
|
| 39 |
-
TMDB_DETAILS_CACHE: dict = {}
|
| 40 |
-
EPISODE_CACHE: dict = {}
|
| 41 |
-
|
| 42 |
-
# Concurrency semaphore for external API calls
|
| 43 |
-
API_SEMAPHORE = asyncio.Semaphore(12)
|
| 44 |
-
|
| 45 |
-
# Minimum title-similarity score to accept a Cinemeta result
|
| 46 |
-
_CINEMETA_THRESHOLD = 0.60
|
| 47 |
-
# Minimum score to accept TMDb first-result (without year bonus it's stricter)
|
| 48 |
-
_TMDB_THRESHOLD = 0.55
|
| 49 |
-
|
| 50 |
-
# ----------------- Image helpers -----------------
|
| 51 |
-
|
| 52 |
def format_tmdb_image(path: str, size="w500") -> str:
|
| 53 |
-
|
| 54 |
-
return ""
|
| 55 |
-
return f"https://image.tmdb.org/t/p/{size}{path}"
|
| 56 |
-
|
| 57 |
|
| 58 |
def get_tmdb_logo(images) -> str:
|
| 59 |
-
|
| 60 |
-
return ""
|
| 61 |
-
logos = getattr(images, "logos", None)
|
| 62 |
if not logos:
|
| 63 |
return ""
|
| 64 |
for logo in logos:
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
if iso_lang == "en" and file_path:
|
| 68 |
-
return format_tmdb_image(file_path, "w300")
|
| 69 |
for logo in logos:
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
return format_tmdb_image(file_path, "w300")
|
| 73 |
return ""
|
| 74 |
|
| 75 |
-
|
| 76 |
def format_imdb_images(imdb_id: str) -> dict:
|
| 77 |
if not imdb_id:
|
| 78 |
return {"poster": "", "backdrop": "", "logo": ""}
|
|
@@ -83,327 +78,261 @@ def format_imdb_images(imdb_id: str) -> dict:
|
|
| 83 |
}
|
| 84 |
|
| 85 |
|
| 86 |
-
# ----------------- ID extraction -----------------
|
| 87 |
-
|
| 88 |
def extract_default_id(text: str) -> str | None:
|
| 89 |
-
"""
|
| 90 |
-
Extract an IMDb (tt…) or TMDb (numeric) ID from arbitrary text.
|
| 91 |
-
|
| 92 |
-
Handles:
|
| 93 |
-
• Bare IMDb IDs: tt1234567
|
| 94 |
-
• IMDb URLs: https://www.imdb.com/title/tt1234567/
|
| 95 |
-
• TMDb movie URLs: https://www.themoviedb.org/movie/12345
|
| 96 |
-
• TMDb TV URLs: https://www.themoviedb.org/tv/12345
|
| 97 |
-
• Bare TMDb IDs are intentionally NOT extracted from plain numbers to
|
| 98 |
-
avoid false matches; they must come via a URL or an explicit override.
|
| 99 |
-
"""
|
| 100 |
if not text:
|
| 101 |
return None
|
| 102 |
-
|
| 103 |
-
# 1. Bare IMDb ID (tt followed by 7–10 digits, word-bounded)
|
| 104 |
-
bare_imdb = re.search(r'\b(tt\d{7,10})\b', text)
|
| 105 |
if bare_imdb:
|
| 106 |
return bare_imdb.group(1)
|
| 107 |
-
|
| 108 |
-
# 2. IMDb URL
|
| 109 |
-
imdb_url = re.search(r'/title/(tt\d+)', text)
|
| 110 |
if imdb_url:
|
| 111 |
return imdb_url.group(1)
|
| 112 |
-
|
| 113 |
-
# 3. TMDb URL (/movie/NNN or /tv/NNN)
|
| 114 |
-
tmdb_url = re.search(r'/(?:movie|tv)/(\d+)', text)
|
| 115 |
if tmdb_url:
|
| 116 |
return tmdb_url.group(1)
|
| 117 |
-
|
| 118 |
return None
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
def _normalize_title(title: str) -> str:
|
| 124 |
-
"""Lower-case, strip leading articles, collapse punctuation to spaces."""
|
| 125 |
if not title:
|
| 126 |
return ""
|
| 127 |
t = title.lower().strip()
|
| 128 |
-
|
| 129 |
-
t = re.sub(r
|
| 130 |
-
|
| 131 |
-
t = re.sub(r'[^\w\s]', ' ', t)
|
| 132 |
-
t = re.sub(r'\s+', ' ', t).strip()
|
| 133 |
-
return t
|
| 134 |
-
|
| 135 |
|
| 136 |
def _title_similarity(t1: str, t2: str) -> float:
|
| 137 |
-
"""Fuzzy similarity between two titles, 0–1."""
|
| 138 |
n1, n2 = _normalize_title(t1), _normalize_title(t2)
|
| 139 |
if not n1 or not n2:
|
| 140 |
return 0.0
|
| 141 |
-
return
|
| 142 |
-
|
| 143 |
|
| 144 |
def _year_from_str(year_val) -> int:
|
| 145 |
-
"""Extract the first 4-digit year from strings like '2023', '2020–', '2019-2022'."""
|
| 146 |
if not year_val:
|
| 147 |
return 0
|
| 148 |
-
m = re.search(r
|
| 149 |
return int(m.group(1)) if m else 0
|
| 150 |
|
| 151 |
-
|
| 152 |
-
def _score_candidate(
|
| 153 |
-
query_title: str,
|
| 154 |
-
query_year: Optional[int],
|
| 155 |
-
result_title: str,
|
| 156 |
-
result_year: int,
|
| 157 |
-
) -> float:
|
| 158 |
-
"""
|
| 159 |
-
Combined score for a single API candidate.
|
| 160 |
-
Base = title similarity; year match adds a bonus.
|
| 161 |
-
"""
|
| 162 |
score = _title_similarity(query_title, result_title)
|
| 163 |
-
|
| 164 |
if query_year and result_year:
|
| 165 |
diff = abs(int(query_year) - result_year)
|
| 166 |
if diff == 0:
|
| 167 |
-
score = min(1.0, score + 0.20)
|
| 168 |
elif diff == 1:
|
| 169 |
-
score = min(1.0, score + 0.07)
|
| 170 |
-
elif diff
|
| 171 |
-
|
| 172 |
-
else:
|
| 173 |
-
score = max(0.0, score - 0.10 * (diff - 2)) # growing penalty
|
| 174 |
-
|
| 175 |
return score
|
| 176 |
|
| 177 |
-
|
| 178 |
def _build_query_variants(title: str, year: Optional[int] = None) -> List[str]:
|
| 179 |
-
|
| 180 |
-
Build an ordered list of search query variants to try when the primary
|
| 181 |
-
query produces a poor match.
|
| 182 |
-
|
| 183 |
-
Order: most-specific → least-specific.
|
| 184 |
-
"""
|
| 185 |
-
variants: List[str] = []
|
| 186 |
-
|
| 187 |
-
# 1. Original title (as parsed by PTN)
|
| 188 |
-
variants.append(title)
|
| 189 |
-
|
| 190 |
-
# 2. Title with year appended (often improves Cinemeta recall for movies)
|
| 191 |
if year:
|
| 192 |
variants.append(f"{title} {year}")
|
| 193 |
|
| 194 |
-
|
| 195 |
-
stripped = re.sub(r'[^\w\s]', ' ', title)
|
| 196 |
-
stripped = re.sub(r'\s+', ' ', stripped).strip()
|
| 197 |
if stripped and stripped.lower() != title.lower():
|
| 198 |
variants.append(stripped)
|
| 199 |
if year:
|
| 200 |
variants.append(f"{stripped} {year}")
|
| 201 |
|
| 202 |
-
|
| 203 |
-
no_article = re.sub(r'^\b(the|a|an)\b\s+', '', title, flags=IGNORECASE).strip()
|
| 204 |
if no_article and no_article.lower() != title.lower():
|
| 205 |
variants.append(no_article)
|
| 206 |
|
| 207 |
-
# Deduplicate, preserve order, drop empty strings
|
| 208 |
seen: set = set()
|
| 209 |
-
|
| 210 |
for v in variants:
|
| 211 |
-
|
| 212 |
-
if
|
| 213 |
-
seen.add(
|
| 214 |
-
|
| 215 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
-
|
| 219 |
|
| 220 |
async def safe_imdb_search(title: str, type_: str, year: Optional[int] = None) -> str | None:
|
| 221 |
-
"""
|
| 222 |
-
Search Cinemeta for *title* (+ optional *year*) with:
|
| 223 |
-
• Multiple query variants (original, stripped, with-year, no-article)
|
| 224 |
-
• Fuzzy title + year scoring across all returned results
|
| 225 |
-
• Acceptance threshold so partial/wrong matches are rejected early
|
| 226 |
-
• INFO log explaining what happened when no confident match is found
|
| 227 |
-
"""
|
| 228 |
cache_key = f"imdb::{type_}::{title}::{year}"
|
| 229 |
if cache_key in IMDB_CACHE:
|
| 230 |
return IMDB_CACHE[cache_key]
|
| 231 |
|
| 232 |
query_variants = _build_query_variants(title, year)
|
| 233 |
-
|
| 234 |
best_id: str | None = None
|
| 235 |
-
best_score
|
| 236 |
-
|
| 237 |
|
| 238 |
for query in query_variants:
|
| 239 |
try:
|
| 240 |
async with API_SEMAPHORE:
|
| 241 |
results = await search_title_multi(query=query, type=type_, limit=8)
|
| 242 |
-
|
| 243 |
for r in results:
|
| 244 |
-
|
| 245 |
-
r_year = _year_from_str(r.get("year", ""))
|
| 246 |
-
score = _score_candidate(title, year, r_title, r_year)
|
| 247 |
-
|
| 248 |
if score > best_score:
|
| 249 |
-
best_score = score
|
| 250 |
-
|
| 251 |
-
best_result_title = r_title
|
| 252 |
-
|
| 253 |
-
# Early-exit: perfect / near-perfect match
|
| 254 |
-
if best_score >= 0.92:
|
| 255 |
break
|
| 256 |
-
|
| 257 |
except Exception as e:
|
| 258 |
LOGGER.warning(f"Cinemeta search variant '{query}' [{type_}] failed: {e}")
|
| 259 |
-
|
| 260 |
-
# Early-exit across variants too
|
| 261 |
-
if best_score >= 0.92:
|
| 262 |
break
|
| 263 |
|
| 264 |
if best_score >= _CINEMETA_THRESHOLD and best_id:
|
| 265 |
-
LOGGER.info(
|
| 266 |
-
f"Cinemeta match: '{title}' (year={year}) → "
|
| 267 |
-
f"'{best_result_title}' [{best_id}] (score={best_score:.2f})"
|
| 268 |
-
)
|
| 269 |
IMDB_CACHE[cache_key] = best_id
|
| 270 |
return best_id
|
| 271 |
|
| 272 |
-
# Log a clear, actionable INFO message so operators know why it fell back
|
| 273 |
if best_id:
|
| 274 |
LOGGER.info(
|
| 275 |
-
f"Cinemeta low-confidence for '{title}' (year={year}, type={type_}) "
|
| 276 |
-
f"
|
| 277 |
-
f"(threshold={_CINEMETA_THRESHOLD}) → falling back to TMDb"
|
| 278 |
)
|
| 279 |
else:
|
| 280 |
-
LOGGER.info(
|
| 281 |
-
f"Cinemeta returned no results for '{title}' (year={year}, type={type_}) "
|
| 282 |
-
f"| tried variants: {query_variants} → falling back to TMDb"
|
| 283 |
-
)
|
| 284 |
|
| 285 |
IMDB_CACHE[cache_key] = None
|
| 286 |
return None
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
async def safe_tmdb_search(title: str, type_: str, year: Optional[int] = None):
|
| 290 |
-
"""
|
| 291 |
-
Search TMDb for *title*, scoring all returned results by title similarity
|
| 292 |
-
and year proximity. Falls back to a year-agnostic retry when the
|
| 293 |
-
year-constrained search yields nothing.
|
| 294 |
-
"""
|
| 295 |
cache_key = f"tmdb_search::{type_}::{title}::{year}"
|
| 296 |
if cache_key in TMDB_SEARCH_CACHE:
|
| 297 |
return TMDB_SEARCH_CACHE[cache_key]
|
| 298 |
|
| 299 |
try:
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
results = (
|
| 303 |
-
await get_tmdb_client().search().movies(query=title, year=year)
|
| 304 |
-
if year
|
| 305 |
-
else await get_tmdb_client().search().movies(query=title)
|
| 306 |
-
)
|
| 307 |
-
# Year-constrained search sometimes returns nothing for new/limited releases
|
| 308 |
-
if not results and year:
|
| 309 |
-
async with API_SEMAPHORE:
|
| 310 |
-
results = await get_tmdb_client().search().movies(query=title)
|
| 311 |
-
else:
|
| 312 |
-
results = await get_tmdb_client().search().tv(query=title)
|
| 313 |
-
|
| 314 |
-
best = _pick_best_tmdb_result(results, title, year, type_)
|
| 315 |
-
|
| 316 |
if best is None and results:
|
| 317 |
-
# Log what the top result actually was so operators can diagnose
|
| 318 |
top = results[0]
|
| 319 |
top_title = getattr(top, "title" if type_ == "movie" else "name", "?")
|
| 320 |
-
LOGGER.info(
|
| 321 |
-
f"TMDb '{title}' (year={year}) top result '{top_title}' "
|
| 322 |
-
f"did not meet threshold – no confident match"
|
| 323 |
-
)
|
| 324 |
-
|
| 325 |
TMDB_SEARCH_CACHE[cache_key] = best
|
| 326 |
return best
|
| 327 |
-
|
| 328 |
except Exception as e:
|
| 329 |
LOGGER.error(f"TMDb search failed for '{title}' [{type_}]: {e}")
|
| 330 |
TMDB_SEARCH_CACHE[cache_key] = None
|
| 331 |
return None
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
|
| 334 |
-
def _pick_best_tmdb_result(results, query_title: str, query_year: Optional[int],
|
| 335 |
-
"""
|
| 336 |
-
Score TMDb search results and return the best one above threshold,
|
| 337 |
-
or None if nothing is confident enough.
|
| 338 |
-
"""
|
| 339 |
if not results:
|
| 340 |
return None
|
| 341 |
|
| 342 |
-
|
| 343 |
-
best_score = 0.0
|
| 344 |
-
|
| 345 |
for item in results:
|
| 346 |
-
|
| 347 |
-
r_title = getattr(item, "title", "") or ""
|
| 348 |
-
release = getattr(item, "release_date", None)
|
| 349 |
-
r_year = getattr(release, "year", 0) if release else 0
|
| 350 |
-
else:
|
| 351 |
-
r_title = getattr(item, "name", "") or ""
|
| 352 |
-
first_air = getattr(item, "first_air_date", None)
|
| 353 |
-
r_year = getattr(first_air, "year", 0) if first_air else 0
|
| 354 |
-
|
| 355 |
score = _score_candidate(query_title, query_year, r_title, r_year)
|
| 356 |
-
|
| 357 |
if score > best_score:
|
| 358 |
-
best_score = score
|
| 359 |
-
best_item = item
|
| 360 |
|
| 361 |
-
|
| 362 |
-
break
|
| 363 |
-
|
| 364 |
-
if best_score >= _TMDB_THRESHOLD and best_item is not None:
|
| 365 |
return best_item
|
| 366 |
|
| 367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
|
|
|
|
| 369 |
|
| 370 |
-
async def
|
| 371 |
-
if
|
| 372 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
try:
|
|
|
|
| 374 |
async with API_SEMAPHORE:
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
details.images = images
|
| 380 |
-
|
| 381 |
-
TMDB_DETAILS_CACHE[movie_id] = details
|
| 382 |
-
return details
|
| 383 |
except Exception as e:
|
| 384 |
-
LOGGER.warning(f"TMDb
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
if
|
| 391 |
-
return TMDB_DETAILS_CACHE[
|
| 392 |
try:
|
|
|
|
| 393 |
async with API_SEMAPHORE:
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
)
|
| 397 |
-
|
| 398 |
-
details.images = images
|
| 399 |
-
TMDB_DETAILS_CACHE[tv_id] = details
|
| 400 |
return details
|
| 401 |
except Exception as e:
|
| 402 |
-
LOGGER.warning(f"TMDb
|
| 403 |
-
TMDB_DETAILS_CACHE[
|
| 404 |
return None
|
| 405 |
|
| 406 |
-
|
| 407 |
async def _tmdb_episode_details(tv_id, season, episode):
|
| 408 |
key = (tv_id, season, episode)
|
| 409 |
if key in EPISODE_CACHE:
|
|
@@ -417,28 +346,163 @@ async def _tmdb_episode_details(tv_id, season, episode):
|
|
| 417 |
EPISODE_CACHE[key] = None
|
| 418 |
return None
|
| 419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
|
| 421 |
-
#
|
| 422 |
-
# Main entry-point
|
| 423 |
-
# =============================================================================
|
| 424 |
|
|
|
|
| 425 |
async def metadata(filename: str, channel: int, msg_id, override_id: str = None) -> dict | None:
|
| 426 |
try:
|
| 427 |
-
parsed =
|
| 428 |
except Exception as e:
|
| 429 |
-
LOGGER.error(f"
|
| 430 |
return None
|
| 431 |
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
multipart_pattern = compile(r'(?:part|cd|disc|disk)[s._-]*\d+(?=\.\w+$)', IGNORECASE)
|
| 435 |
-
if multipart_pattern.search(filename):
|
| 436 |
-
LOGGER.info(f"Skipping {filename}: seems to be a split video file that is not supposed to combine in stremio use .mkv.001, .mkv.002 split files")
|
| 437 |
return None
|
| 438 |
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
if "excess" in parsed and any("combined" in item.lower() for item in parsed["excess"]):
|
| 442 |
LOGGER.info(f"Skipping {filename}: contains 'combined'")
|
| 443 |
return None
|
| 444 |
|
|
@@ -449,7 +513,7 @@ async def metadata(filename: str, channel: int, msg_id, override_id: str = None)
|
|
| 449 |
season = parsed.get("season")
|
| 450 |
episode = parsed.get("episode")
|
| 451 |
year = parsed.get("year")
|
| 452 |
-
quality = parsed.get("
|
| 453 |
|
| 454 |
if isinstance(season, list) or isinstance(episode, list):
|
| 455 |
LOGGER.warning(f"Invalid season/episode format for {filename}: {parsed}")
|
|
@@ -464,35 +528,14 @@ async def metadata(filename: str, channel: int, msg_id, override_id: str = None)
|
|
| 464 |
LOGGER.info(f"No title parsed from: {filename} (parsed={parsed})")
|
| 465 |
return None
|
| 466 |
|
| 467 |
-
|
| 468 |
-
default_id = None
|
| 469 |
-
if override_id:
|
| 470 |
-
try:
|
| 471 |
-
default_id = extract_default_id(override_id) or override_id
|
| 472 |
-
except Exception:
|
| 473 |
-
pass
|
| 474 |
-
|
| 475 |
-
if not default_id:
|
| 476 |
-
try:
|
| 477 |
-
default_id = extract_default_id(Backend.USE_DEFAULT_ID)
|
| 478 |
-
except Exception:
|
| 479 |
-
pass
|
| 480 |
|
| 481 |
-
if not default_id:
|
| 482 |
-
try:
|
| 483 |
-
default_id = extract_default_id(filename)
|
| 484 |
-
except Exception:
|
| 485 |
-
pass
|
| 486 |
-
|
| 487 |
-
data = {"chat_id": channel, "msg_id": msg_id}
|
| 488 |
try:
|
| 489 |
-
encoded_string = await encode_string(
|
| 490 |
except Exception:
|
| 491 |
encoded_string = None
|
| 492 |
|
| 493 |
-
group_key = None
|
| 494 |
-
if split_info:
|
| 495 |
-
group_key = f"{channel}:{quality}:{split_info[0]}"
|
| 496 |
|
| 497 |
try:
|
| 498 |
if season and episode:
|
|
@@ -510,487 +553,186 @@ async def metadata(filename: str, channel: int, msg_id, override_id: str = None)
|
|
| 510 |
return None
|
| 511 |
|
| 512 |
|
| 513 |
-
#
|
| 514 |
-
|
| 515 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
|
|
|
|
| 517 |
async def fetch_tv_metadata(title, season, episode, encoded_string, year=None, quality=None, default_id=None) -> dict | None:
|
| 518 |
-
imdb_id =
|
| 519 |
-
tmdb_id = None
|
| 520 |
imdb_tv = None
|
| 521 |
imdb_ep = None
|
| 522 |
-
use_tmdb = False
|
| 523 |
-
explicit_imdb_id = False
|
| 524 |
-
|
| 525 |
-
# ------------------------------------------------------------------
|
| 526 |
-
# 1. Handle explicit default ID
|
| 527 |
-
# ------------------------------------------------------------------
|
| 528 |
-
if default_id:
|
| 529 |
-
default_id = str(default_id)
|
| 530 |
-
if default_id.startswith("tt"):
|
| 531 |
-
imdb_id = default_id
|
| 532 |
-
explicit_imdb_id = True
|
| 533 |
-
elif default_id.isdigit():
|
| 534 |
-
tmdb_id = int(default_id)
|
| 535 |
-
use_tmdb = True
|
| 536 |
|
| 537 |
-
# ------------------------------------------------------------------
|
| 538 |
-
# 2. No ID → fuzzy Cinemeta search with query variants
|
| 539 |
-
# ------------------------------------------------------------------
|
| 540 |
if not imdb_id and not tmdb_id:
|
| 541 |
imdb_id = await safe_imdb_search(title, "tvSeries", year)
|
| 542 |
use_tmdb = not bool(imdb_id)
|
| 543 |
|
| 544 |
-
# ------------------------------------------------------------------
|
| 545 |
-
# 3. IMDb / Cinemeta detail fetch
|
| 546 |
-
# ------------------------------------------------------------------
|
| 547 |
if imdb_id and not use_tmdb:
|
| 548 |
try:
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
else:
|
| 552 |
-
async with API_SEMAPHORE:
|
| 553 |
-
imdb_tv = await get_detail(imdb_id=imdb_id, media_type="tvSeries")
|
| 554 |
-
IMDB_CACHE[imdb_id] = imdb_tv
|
| 555 |
-
|
| 556 |
-
ep_key = f"{imdb_id}::{season}::{episode}"
|
| 557 |
-
if ep_key in EPISODE_CACHE:
|
| 558 |
-
imdb_ep = EPISODE_CACHE[ep_key]
|
| 559 |
-
else:
|
| 560 |
-
async with API_SEMAPHORE:
|
| 561 |
-
imdb_ep = await get_season(imdb_id=imdb_id, season_id=season, episode_id=episode)
|
| 562 |
-
EPISODE_CACHE[ep_key] = imdb_ep
|
| 563 |
-
|
| 564 |
except Exception as e:
|
| 565 |
-
LOGGER.warning(f"IMDb TV fetch failed [{imdb_id}]
|
| 566 |
-
imdb_tv = None
|
| 567 |
-
imdb_ep = None
|
| 568 |
use_tmdb = True
|
| 569 |
|
| 570 |
-
# -
|
| 571 |
-
# 4. Validate IMDb result against query title (guard against Cinemeta
|
| 572 |
-
# returning a stale wrong hit despite the fuzzy pre-filter).
|
| 573 |
-
# Skipped when the IMDb ID was supplied explicitly, since the user
|
| 574 |
-
# deliberately chose that exact ID.
|
| 575 |
-
# ------------------------------------------------------------------
|
| 576 |
if imdb_tv and not use_tmdb and not explicit_imdb_id:
|
| 577 |
-
|
| 578 |
-
sim = _title_similarity(title, matched_title)
|
| 579 |
if sim < _CINEMETA_THRESHOLD:
|
| 580 |
-
LOGGER.info(
|
| 581 |
-
f"IMDb detail title mismatch for '{title}': "
|
| 582 |
-
f"got '{matched_title}' (sim={sim:.2f}) → switching to TMDb"
|
| 583 |
-
)
|
| 584 |
imdb_tv = None
|
| 585 |
use_tmdb = True
|
| 586 |
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
# ------------------------------------------------------------------
|
| 590 |
-
must_use_tmdb = use_tmdb or not imdb_tv
|
| 591 |
-
|
| 592 |
-
if must_use_tmdb:
|
| 593 |
-
LOGGER.info(f"No valid Cinemeta TV data for '{title}' S{season:02d}E{episode:02d} → using TMDb")
|
| 594 |
-
|
| 595 |
if not tmdb_id:
|
| 596 |
-
|
| 597 |
-
tmdb_search = await safe_tmdb_search(title, "tv", year)
|
| 598 |
-
if not tmdb_search and year:
|
| 599 |
-
tmdb_search = await safe_tmdb_search(title, "tv", None)
|
| 600 |
-
|
| 601 |
if not tmdb_search:
|
| 602 |
-
LOGGER.info(
|
| 603 |
-
f"No TMDb TV result for '{title}' S{season:02d}E{episode:02d} "
|
| 604 |
-
f"(year={year}) – metadata unavailable"
|
| 605 |
-
)
|
| 606 |
return None
|
| 607 |
tmdb_id = tmdb_search.id
|
| 608 |
|
| 609 |
-
tv = await
|
| 610 |
if not tv:
|
| 611 |
LOGGER.info(f"TMDb TV details failed for id={tmdb_id} ('{title}')")
|
| 612 |
return None
|
| 613 |
-
|
| 614 |
ep = await _tmdb_episode_details(tmdb_id, season, episode)
|
|
|
|
| 615 |
|
| 616 |
-
|
| 617 |
-
cast_arr = getattr(credits, "cast", []) or []
|
| 618 |
-
cast = [
|
| 619 |
-
getattr(c, "name", None) or getattr(c, "original_name", None)
|
| 620 |
-
for c in cast_arr
|
| 621 |
-
]
|
| 622 |
|
| 623 |
-
ep_runtime = getattr(ep, "runtime", None) if ep else None
|
| 624 |
-
series_runtime = tv.episode_run_time[0] if getattr(tv, "episode_run_time", None) else None
|
| 625 |
-
runtime_val = ep_runtime or series_runtime
|
| 626 |
-
runtime = f"{runtime_val} min" if runtime_val else ""
|
| 627 |
-
|
| 628 |
-
return {
|
| 629 |
-
"tmdb_id": tv.id,
|
| 630 |
-
"imdb_id": getattr(getattr(tv, "external_ids", None), "imdb_id", None),
|
| 631 |
-
"title": tv.name,
|
| 632 |
-
"year": getattr(tv.first_air_date, "year", 0) if getattr(tv, "first_air_date", None) else 0,
|
| 633 |
-
"rate": getattr(tv, "vote_average", 0) or 0,
|
| 634 |
-
"description": tv.overview or "",
|
| 635 |
-
"poster": format_tmdb_image(tv.poster_path),
|
| 636 |
-
"backdrop": format_tmdb_image(tv.backdrop_path, "original"),
|
| 637 |
-
"logo": get_tmdb_logo(getattr(tv, "images", None)),
|
| 638 |
-
"genres": [g.name for g in (tv.genres or [])],
|
| 639 |
-
"media_type": "tv",
|
| 640 |
-
"cast": cast,
|
| 641 |
-
"runtime": str(runtime),
|
| 642 |
-
"season_number": season,
|
| 643 |
-
"episode_number": episode,
|
| 644 |
-
"episode_title": getattr(ep, "name", f"S{season:02d}E{episode:02d}") if ep else f"S{season:02d}E{episode:02d}",
|
| 645 |
-
"episode_backdrop": format_tmdb_image(getattr(ep, "still_path", None), "original") if ep else "",
|
| 646 |
-
"episode_overview": getattr(ep, "overview", "") if ep else "",
|
| 647 |
-
"episode_released": (
|
| 648 |
-
ep.air_date.strftime("%Y-%m-%dT05:00:00.000Z")
|
| 649 |
-
if getattr(ep, "air_date", None)
|
| 650 |
-
else ""
|
| 651 |
-
),
|
| 652 |
-
"quality": quality,
|
| 653 |
-
"encoded_string": encoded_string,
|
| 654 |
-
}
|
| 655 |
-
|
| 656 |
-
# ------------------------------------------------------------------
|
| 657 |
-
# 6. IMDb / Cinemeta path
|
| 658 |
-
# ------------------------------------------------------------------
|
| 659 |
-
imdb = imdb_tv or {}
|
| 660 |
-
ep = imdb_ep or {}
|
| 661 |
-
images = format_imdb_images(imdb_id)
|
| 662 |
-
|
| 663 |
-
return {
|
| 664 |
-
"tmdb_id": imdb.get("moviedb_id") or (imdb_id.replace("tt", "") if imdb_id else None),
|
| 665 |
-
"imdb_id": imdb_id,
|
| 666 |
-
"title": imdb.get("title", title),
|
| 667 |
-
"year": imdb.get("releaseDetailed", {}).get("year", 0),
|
| 668 |
-
"rate": imdb.get("rating", {}).get("star", 0),
|
| 669 |
-
"description": imdb.get("plot", ""),
|
| 670 |
-
"poster": images["poster"],
|
| 671 |
-
"backdrop": images["backdrop"],
|
| 672 |
-
"logo": images["logo"],
|
| 673 |
-
"cast": imdb.get("cast", []),
|
| 674 |
-
"runtime": str(imdb.get("runtime") or ""),
|
| 675 |
-
"genres": imdb.get("genre", []),
|
| 676 |
-
"media_type": "tv",
|
| 677 |
-
"season_number": season,
|
| 678 |
-
"episode_number": episode,
|
| 679 |
-
"episode_title": ep.get("title", f"S{season:02d}E{episode:02d}"),
|
| 680 |
-
"episode_backdrop": ep.get("image", ""),
|
| 681 |
-
"episode_overview": ep.get("plot", ""),
|
| 682 |
-
"episode_released": str(ep.get("released", "")),
|
| 683 |
-
"quality": quality,
|
| 684 |
-
"encoded_string": encoded_string,
|
| 685 |
-
}
|
| 686 |
|
|
|
|
| 687 |
|
| 688 |
-
#
|
| 689 |
-
# Movie metadata
|
| 690 |
-
# =============================================================================
|
| 691 |
-
|
| 692 |
async def fetch_movie_metadata(title, encoded_string, year=None, quality=None, default_id=None) -> dict | None:
|
| 693 |
-
imdb_id =
|
| 694 |
-
tmdb_id = None
|
| 695 |
imdb_details = None
|
| 696 |
-
use_tmdb = False
|
| 697 |
-
explicit_imdb_id = False
|
| 698 |
-
|
| 699 |
-
# ------------------------------------------------------------------
|
| 700 |
-
# 1. Explicit default ID
|
| 701 |
-
# ------------------------------------------------------------------
|
| 702 |
-
if default_id:
|
| 703 |
-
default_id = str(default_id).strip()
|
| 704 |
-
if default_id.startswith("tt"):
|
| 705 |
-
imdb_id = default_id
|
| 706 |
-
explicit_imdb_id = True
|
| 707 |
-
elif default_id.isdigit():
|
| 708 |
-
tmdb_id = int(default_id)
|
| 709 |
-
use_tmdb = True
|
| 710 |
|
| 711 |
-
# ------------------------------------------------------------------
|
| 712 |
-
# 2. No ID → fuzzy Cinemeta search (tries year variants internally)
|
| 713 |
-
# ------------------------------------------------------------------
|
| 714 |
if not imdb_id and not tmdb_id:
|
| 715 |
imdb_id = await safe_imdb_search(title, "movie", year)
|
| 716 |
use_tmdb = not bool(imdb_id)
|
| 717 |
|
| 718 |
-
# ------------------------------------------------------------------
|
| 719 |
-
# 3. IMDb detail fetch
|
| 720 |
-
# ------------------------------------------------------------------
|
| 721 |
if imdb_id and not use_tmdb:
|
| 722 |
try:
|
| 723 |
-
|
| 724 |
-
imdb_details = IMDB_CACHE[imdb_id]
|
| 725 |
-
else:
|
| 726 |
-
async with API_SEMAPHORE:
|
| 727 |
-
imdb_details = await get_detail(imdb_id=imdb_id, media_type="movie")
|
| 728 |
-
IMDB_CACHE[imdb_id] = imdb_details
|
| 729 |
-
|
| 730 |
except Exception as e:
|
| 731 |
-
LOGGER.warning(f"IMDb movie fetch failed [{title}]
|
| 732 |
imdb_details = None
|
| 733 |
use_tmdb = True
|
| 734 |
|
| 735 |
-
# -
|
| 736 |
-
# 4. Validate IMDb result title against query
|
| 737 |
-
# (skipped when the IMDb ID was supplied explicitly — e.g. manual
|
| 738 |
-
# /set or web "Rescan Metadata" — since the user chose the exact ID)
|
| 739 |
-
# ------------------------------------------------------------------
|
| 740 |
if imdb_details and not use_tmdb and not explicit_imdb_id:
|
| 741 |
-
|
| 742 |
-
sim = _title_similarity(title, matched_title)
|
| 743 |
if sim < _CINEMETA_THRESHOLD:
|
| 744 |
-
LOGGER.info(
|
| 745 |
-
f"IMDb detail title mismatch for '{title}': "
|
| 746 |
-
f"got '{matched_title}' (sim={sim:.2f}) → switching to TMDb"
|
| 747 |
-
)
|
| 748 |
imdb_details = None
|
| 749 |
use_tmdb = True
|
| 750 |
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
# ------------------------------------------------------------------
|
| 754 |
-
must_use_tmdb = use_tmdb or not imdb_details
|
| 755 |
-
|
| 756 |
-
if must_use_tmdb:
|
| 757 |
-
LOGGER.info(f"No valid Cinemeta movie data for '{title}' (year={year}) → using TMDb")
|
| 758 |
-
|
| 759 |
if not tmdb_id:
|
| 760 |
-
tmdb_result = await safe_tmdb_search(title, "movie", year)
|
| 761 |
-
# Retry without year restriction for newer/unlisted titles
|
| 762 |
-
if not tmdb_result and year:
|
| 763 |
-
tmdb_result = await safe_tmdb_search(title, "movie", None)
|
| 764 |
-
|
| 765 |
if not tmdb_result:
|
| 766 |
-
LOGGER.info(
|
| 767 |
-
f"No TMDb movie found for '{title}' (year={year}) "
|
| 768 |
-
f"– metadata unavailable"
|
| 769 |
-
)
|
| 770 |
return None
|
| 771 |
tmdb_id = tmdb_result.id
|
| 772 |
|
| 773 |
-
movie = await
|
| 774 |
if not movie:
|
| 775 |
LOGGER.info(f"TMDb movie details failed for id={tmdb_id} ('{title}')")
|
| 776 |
return None
|
|
|
|
| 777 |
|
| 778 |
-
|
| 779 |
-
cast_arr = getattr(credits, "cast", []) or []
|
| 780 |
-
cast_names = [
|
| 781 |
-
getattr(c, "name", None) or getattr(c, "original_name", None)
|
| 782 |
-
for c in cast_arr
|
| 783 |
-
]
|
| 784 |
|
| 785 |
-
runtime_val = getattr(movie, "runtime", None)
|
| 786 |
-
runtime = f"{runtime_val} min" if runtime_val else ""
|
| 787 |
|
| 788 |
-
|
| 789 |
-
"tmdb_id": movie.id,
|
| 790 |
-
"imdb_id": getattr(movie.external_ids, "imdb_id", None),
|
| 791 |
-
"title": movie.title,
|
| 792 |
-
"year": getattr(movie.release_date, "year", 0) if getattr(movie, "release_date", None) else 0,
|
| 793 |
-
"rate": getattr(movie, "vote_average", 0) or 0,
|
| 794 |
-
"description": movie.overview or "",
|
| 795 |
-
"poster": format_tmdb_image(movie.poster_path),
|
| 796 |
-
"backdrop": format_tmdb_image(movie.backdrop_path, "original"),
|
| 797 |
-
"logo": get_tmdb_logo(getattr(movie, "images", None)),
|
| 798 |
-
"cast": cast_names,
|
| 799 |
-
"runtime": str(runtime),
|
| 800 |
-
"media_type": "movie",
|
| 801 |
-
"genres": [g.name for g in (movie.genres or [])],
|
| 802 |
-
"quality": quality,
|
| 803 |
-
"encoded_string": encoded_string,
|
| 804 |
-
}
|
| 805 |
-
|
| 806 |
-
# ------------------------------------------------------------------
|
| 807 |
-
# 6. IMDb / Cinemeta path
|
| 808 |
-
# ------------------------------------------------------------------
|
| 809 |
-
images = format_imdb_images(imdb_id)
|
| 810 |
-
imdb = imdb_details or {}
|
| 811 |
|
|
|
|
|
|
|
| 812 |
return {
|
| 813 |
-
"
|
|
|
|
|
|
|
| 814 |
"imdb_id": imdb_id,
|
| 815 |
-
"
|
| 816 |
-
"
|
| 817 |
-
"
|
| 818 |
-
"
|
| 819 |
-
"poster": images["poster"],
|
| 820 |
-
"backdrop": images["backdrop"],
|
| 821 |
-
"logo": images["logo"],
|
| 822 |
-
"cast": imdb.get("cast", []),
|
| 823 |
-
"runtime": str(imdb.get("runtime") or ""),
|
| 824 |
-
"media_type": "movie",
|
| 825 |
-
"genres": imdb.get("genre", []),
|
| 826 |
-
"quality": quality,
|
| 827 |
-
"encoded_string": encoded_string,
|
| 828 |
}
|
| 829 |
|
| 830 |
|
| 831 |
-
#
|
| 832 |
-
|
| 833 |
-
# =============================================================================
|
| 834 |
-
|
| 835 |
-
async def search_movie_candidates(query: str, year: int | None = None, limit: int = 8) -> list[dict]:
|
| 836 |
query = (query or "").strip()
|
| 837 |
if not query:
|
| 838 |
return []
|
| 839 |
|
|
|
|
| 840 |
results: list[dict] = []
|
| 841 |
seen: set[tuple[str, str]] = set()
|
| 842 |
|
| 843 |
try:
|
| 844 |
-
|
| 845 |
-
if
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
"year": imdb_result.get("year", ""),
|
| 853 |
-
"imdb_id": imdb_result.get("id"),
|
| 854 |
-
"tmdb_id": imdb_result.get("moviedb_id"),
|
| 855 |
-
"poster": imdb_result.get("poster", ""),
|
| 856 |
-
"backdrop": "",
|
| 857 |
-
"subtitle": "IMDb / Cinemeta",
|
| 858 |
-
})
|
| 859 |
except Exception as e:
|
| 860 |
-
LOGGER.warning(f"IMDb
|
| 861 |
|
| 862 |
try:
|
| 863 |
-
|
| 864 |
-
tmdb_results = (
|
| 865 |
-
await get_tmdb_client().search().movies(query=query, year=year)
|
| 866 |
-
if year
|
| 867 |
-
else await get_tmdb_client().search().movies(query=query)
|
| 868 |
-
)
|
| 869 |
-
|
| 870 |
for item in (tmdb_results or [])[:limit]:
|
| 871 |
tmdb_id = getattr(item, "id", None)
|
| 872 |
-
if not tmdb_id:
|
| 873 |
-
continue
|
| 874 |
-
|
| 875 |
-
imdb_id = None
|
| 876 |
-
try:
|
| 877 |
-
details = await _tmdb_movie_details(tmdb_id)
|
| 878 |
-
ext = getattr(details, "external_ids", None) if details else None
|
| 879 |
-
imdb_id = getattr(ext, "imdb_id", None) if ext else None
|
| 880 |
-
except Exception:
|
| 881 |
-
pass
|
| 882 |
-
|
| 883 |
-
key = ("tmdb", str(tmdb_id))
|
| 884 |
-
if key in seen:
|
| 885 |
continue
|
| 886 |
-
seen.add(
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
"
|
| 893 |
-
"
|
| 894 |
-
|
| 895 |
-
"imdb_id": imdb_id,
|
| 896 |
-
"tmdb_id": tmdb_id,
|
| 897 |
-
"poster": format_tmdb_image(getattr(item, "poster_path", None)),
|
| 898 |
-
"backdrop": format_tmdb_image(getattr(item, "backdrop_path", None), "original"),
|
| 899 |
-
"subtitle": "TMDb",
|
| 900 |
-
})
|
| 901 |
except Exception as e:
|
| 902 |
-
LOGGER.warning(f"TMDb
|
| 903 |
|
| 904 |
return results[:limit]
|
| 905 |
|
| 906 |
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
return []
|
| 911 |
-
|
| 912 |
-
results: list[dict] = []
|
| 913 |
-
seen: set[tuple[str, str]] = set()
|
| 914 |
-
|
| 915 |
-
try:
|
| 916 |
-
imdb_result = await search_title(query=query, type="tvSeries")
|
| 917 |
-
if imdb_result and imdb_result.get("id"):
|
| 918 |
-
key = ("imdb", imdb_result["id"])
|
| 919 |
-
if key not in seen:
|
| 920 |
-
seen.add(key)
|
| 921 |
-
results.append({
|
| 922 |
-
"source": "imdb",
|
| 923 |
-
"title": imdb_result.get("title", ""),
|
| 924 |
-
"year": imdb_result.get("year", ""),
|
| 925 |
-
"imdb_id": imdb_result.get("id"),
|
| 926 |
-
"tmdb_id": imdb_result.get("moviedb_id"),
|
| 927 |
-
"poster": imdb_result.get("poster", ""),
|
| 928 |
-
"backdrop": "",
|
| 929 |
-
"subtitle": "IMDb / Cinemeta",
|
| 930 |
-
})
|
| 931 |
-
except Exception as e:
|
| 932 |
-
LOGGER.warning(f"IMDb TV candidate search failed for '{query}': {e}")
|
| 933 |
-
|
| 934 |
-
try:
|
| 935 |
-
async with API_SEMAPHORE:
|
| 936 |
-
tmdb_results = await get_tmdb_client().search().tv(query=query)
|
| 937 |
-
|
| 938 |
-
for item in (tmdb_results or [])[:limit]:
|
| 939 |
-
tmdb_id = getattr(item, "id", None)
|
| 940 |
-
if not tmdb_id:
|
| 941 |
-
continue
|
| 942 |
-
|
| 943 |
-
imdb_id = None
|
| 944 |
-
try:
|
| 945 |
-
details = await _tmdb_tv_details(tmdb_id)
|
| 946 |
-
ext = getattr(details, "external_ids", None) if details else None
|
| 947 |
-
imdb_id = getattr(ext, "imdb_id", None) if ext else None
|
| 948 |
-
except Exception:
|
| 949 |
-
pass
|
| 950 |
-
|
| 951 |
-
key = ("tmdb", str(tmdb_id))
|
| 952 |
-
if key in seen:
|
| 953 |
-
continue
|
| 954 |
-
seen.add(key)
|
| 955 |
-
|
| 956 |
-
first_air_date = getattr(item, "first_air_date", None)
|
| 957 |
-
year_value = getattr(first_air_date, "year", None) if first_air_date else None
|
| 958 |
-
|
| 959 |
-
results.append({
|
| 960 |
-
"source": "tmdb",
|
| 961 |
-
"title": getattr(item, "name", "") or "",
|
| 962 |
-
"year": year_value or "",
|
| 963 |
-
"imdb_id": imdb_id,
|
| 964 |
-
"tmdb_id": tmdb_id,
|
| 965 |
-
"poster": format_tmdb_image(getattr(item, "poster_path", None)),
|
| 966 |
-
"backdrop": format_tmdb_image(getattr(item, "backdrop_path", None), "original"),
|
| 967 |
-
"subtitle": "TMDb",
|
| 968 |
-
})
|
| 969 |
-
except Exception as e:
|
| 970 |
-
LOGGER.warning(f"TMDb TV candidate search failed for '{query}': {e}")
|
| 971 |
-
|
| 972 |
-
return results[:limit]
|
| 973 |
|
| 974 |
|
| 975 |
-
#
|
| 976 |
-
|
| 977 |
-
|
| 978 |
|
| 979 |
-
async def fetch_selected_movie_metadata(selected_id: str) -> dict | None:
|
| 980 |
-
selected_id = str(selected_id).strip()
|
| 981 |
-
if not selected_id:
|
| 982 |
-
return None
|
| 983 |
|
| 984 |
-
|
| 985 |
-
title="manual-rescan",
|
| 986 |
-
encoded_string=None,
|
| 987 |
-
year=None,
|
| 988 |
-
quality=None,
|
| 989 |
-
default_id=selected_id
|
| 990 |
-
)
|
| 991 |
-
if not data:
|
| 992 |
-
return None
|
| 993 |
|
|
|
|
|
|
|
| 994 |
return {
|
| 995 |
"tmdb_id": data.get("tmdb_id"),
|
| 996 |
"imdb_id": data.get("imdb_id"),
|
|
@@ -1004,28 +746,29 @@ async def fetch_selected_movie_metadata(selected_id: str) -> dict | None:
|
|
| 1004 |
"genres": data.get("genres", []),
|
| 1005 |
"cast": data.get("cast", []),
|
| 1006 |
"runtime": data.get("runtime"),
|
| 1007 |
-
"media_type":
|
| 1008 |
}
|
| 1009 |
|
| 1010 |
|
| 1011 |
-
|
|
|
|
| 1012 |
selected_id = str(selected_id).strip()
|
| 1013 |
if not selected_id:
|
| 1014 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1015 |
|
| 1016 |
-
imdb_id = None
|
| 1017 |
-
tmdb_id = None
|
| 1018 |
-
imdb_tv = None
|
| 1019 |
-
use_tmdb = False
|
| 1020 |
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
else:
|
| 1027 |
return None
|
| 1028 |
|
|
|
|
| 1029 |
if imdb_id and not use_tmdb:
|
| 1030 |
try:
|
| 1031 |
imdb_tv = await get_detail(imdb_id=imdb_id, media_type="tvSeries")
|
|
@@ -1039,36 +782,26 @@ async def fetch_selected_tv_metadata(selected_id: str) -> dict | None:
|
|
| 1039 |
tmdb_id = int(imdb_tv["moviedb_id"])
|
| 1040 |
except Exception:
|
| 1041 |
tmdb_id = None
|
| 1042 |
-
|
| 1043 |
if not tmdb_id:
|
| 1044 |
return None
|
| 1045 |
|
| 1046 |
-
tv = await
|
| 1047 |
if not tv:
|
| 1048 |
return None
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
cast_arr = getattr(credits, "cast", []) or []
|
| 1052 |
-
cast = [
|
| 1053 |
-
getattr(c, "name", None) or getattr(c, "original_name", None)
|
| 1054 |
-
for c in cast_arr
|
| 1055 |
-
]
|
| 1056 |
-
|
| 1057 |
-
runtime_val = tv.episode_run_time[0] if getattr(tv, "episode_run_time", None) else None
|
| 1058 |
-
runtime = f"{runtime_val} min" if runtime_val else ""
|
| 1059 |
-
|
| 1060 |
return {
|
| 1061 |
"tmdb_id": tv.id,
|
| 1062 |
"imdb_id": getattr(getattr(tv, "external_ids", None), "imdb_id", None),
|
| 1063 |
"title": tv.name,
|
| 1064 |
-
"release_year": getattr(
|
| 1065 |
"rating": getattr(tv, "vote_average", 0) or 0,
|
| 1066 |
"description": tv.overview or "",
|
| 1067 |
"poster": format_tmdb_image(tv.poster_path),
|
| 1068 |
"backdrop": format_tmdb_image(tv.backdrop_path, "original"),
|
| 1069 |
"logo": get_tmdb_logo(getattr(tv, "images", None)),
|
| 1070 |
"genres": [g.name for g in (tv.genres or [])],
|
| 1071 |
-
"cast":
|
| 1072 |
"runtime": str(runtime),
|
| 1073 |
"media_type": "tv",
|
| 1074 |
}
|
|
|
|
| 1 |
import asyncio
|
|
|
|
|
|
|
| 2 |
import re
|
| 3 |
+
import traceback
|
|
|
|
| 4 |
from typing import Optional, List
|
| 5 |
|
| 6 |
+
import PTN
|
| 7 |
+
|
|
|
|
| 8 |
import Backend
|
| 9 |
from Backend.logger import LOGGER
|
| 10 |
+
from Backend.helper.imdb import get_detail, get_season, search_title, search_title_multi
|
| 11 |
+
from Backend.helper.settings_manager import SettingsManager
|
| 12 |
from Backend.helper.encrypt import encode_string
|
| 13 |
from Backend.helper.split_files import parse_split_info
|
| 14 |
+
from themoviedb import aioTMDb
|
| 15 |
+
from rapidfuzz import fuzz
|
| 16 |
+
from guessit import guessit as _guessit
|
| 17 |
+
from difflib import SequenceMatcher
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
def _fuzzy_ratio(a: str, b: str) -> float:
|
| 21 |
+
return fuzz.token_set_ratio(a, b) / 100.0
|
| 22 |
+
except Exception:
|
| 23 |
+
def _fuzzy_ratio(a: str, b: str) -> float:
|
| 24 |
+
return SequenceMatcher(None, a, b).ratio()
|
| 25 |
|
|
|
|
| 26 |
|
| 27 |
+
_CINEMETA_THRESHOLD = 0.60
|
| 28 |
+
_TMDB_THRESHOLD = 0.55
|
| 29 |
+
_STRONG_MATCH = 0.92
|
| 30 |
+
_ALT_TITLE_LOOKUPS = 5
|
| 31 |
+
|
| 32 |
+
IMDB_CACHE: dict = {}
|
| 33 |
+
TMDB_SEARCH_CACHE: dict = {}
|
| 34 |
+
TMDB_DETAILS_CACHE: dict = {}
|
| 35 |
+
EPISODE_CACHE: dict = {}
|
| 36 |
+
ALT_TITLES_CACHE: dict = {}
|
| 37 |
+
|
| 38 |
+
API_SEMAPHORE = asyncio.Semaphore(12)
|
| 39 |
+
|
| 40 |
+
_MULTIPART_RE = re.compile(r"(?:part|cd|disc|disk)[s._-]*\d+(?=\.\w+$)", re.IGNORECASE)
|
| 41 |
|
| 42 |
_tmdb_client: aioTMDb | None = None
|
| 43 |
_tmdb_client_key: str | None = None
|
| 44 |
|
| 45 |
|
| 46 |
+
# Return a cached TMDb client, rebuilding it when the configured API key changes.
|
| 47 |
def get_tmdb_client() -> aioTMDb:
|
| 48 |
global _tmdb_client, _tmdb_client_key
|
|
|
|
| 49 |
current_key = SettingsManager.current().tmdb_api
|
| 50 |
if _tmdb_client is None or _tmdb_client_key != current_key:
|
| 51 |
_tmdb_client = aioTMDb(key=current_key, language="en-US", region="US")
|
| 52 |
_tmdb_client_key = current_key
|
|
|
|
| 53 |
return _tmdb_client
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
def format_tmdb_image(path: str, size="w500") -> str:
|
| 57 |
+
return f"https://image.tmdb.org/t/p/{size}{path}" if path else ""
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
def get_tmdb_logo(images) -> str:
|
| 60 |
+
logos = getattr(images, "logos", None) if images else None
|
|
|
|
|
|
|
| 61 |
if not logos:
|
| 62 |
return ""
|
| 63 |
for logo in logos:
|
| 64 |
+
if getattr(logo, "iso_639_1", None) == "en" and getattr(logo, "file_path", None):
|
| 65 |
+
return format_tmdb_image(logo.file_path, "w300")
|
|
|
|
|
|
|
| 66 |
for logo in logos:
|
| 67 |
+
if getattr(logo, "file_path", None):
|
| 68 |
+
return format_tmdb_image(logo.file_path, "w300")
|
|
|
|
| 69 |
return ""
|
| 70 |
|
|
|
|
| 71 |
def format_imdb_images(imdb_id: str) -> dict:
|
| 72 |
if not imdb_id:
|
| 73 |
return {"poster": "", "backdrop": "", "logo": ""}
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
| 81 |
def extract_default_id(text: str) -> str | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
if not text:
|
| 83 |
return None
|
| 84 |
+
bare_imdb = re.search(r"\b(tt\d{7,10})\b", text)
|
|
|
|
|
|
|
| 85 |
if bare_imdb:
|
| 86 |
return bare_imdb.group(1)
|
| 87 |
+
imdb_url = re.search(r"/title/(tt\d+)", text)
|
|
|
|
|
|
|
| 88 |
if imdb_url:
|
| 89 |
return imdb_url.group(1)
|
| 90 |
+
tmdb_url = re.search(r"/(?:movie|tv)/(\d+)", text)
|
|
|
|
|
|
|
| 91 |
if tmdb_url:
|
| 92 |
return tmdb_url.group(1)
|
|
|
|
| 93 |
return None
|
| 94 |
|
| 95 |
+
def _split_default_id(default_id) -> tuple[str | None, int | None, bool, bool]:
|
| 96 |
+
if not default_id:
|
| 97 |
+
return None, None, False, False
|
| 98 |
+
value = str(default_id).strip()
|
| 99 |
+
if value.startswith("tt"):
|
| 100 |
+
return value, None, True, False
|
| 101 |
+
if value.isdigit():
|
| 102 |
+
return None, int(value), False, True
|
| 103 |
+
return None, None, False, False
|
| 104 |
|
| 105 |
def _normalize_title(title: str) -> str:
|
|
|
|
| 106 |
if not title:
|
| 107 |
return ""
|
| 108 |
t = title.lower().strip()
|
| 109 |
+
t = re.sub(r"^\b(the|a|an)\b\s+", "", t)
|
| 110 |
+
t = re.sub(r"[^\w\s]", " ", t)
|
| 111 |
+
return re.sub(r"\s+", " ", t).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
def _title_similarity(t1: str, t2: str) -> float:
|
|
|
|
| 114 |
n1, n2 = _normalize_title(t1), _normalize_title(t2)
|
| 115 |
if not n1 or not n2:
|
| 116 |
return 0.0
|
| 117 |
+
return _fuzzy_ratio(n1, n2)
|
|
|
|
| 118 |
|
| 119 |
def _year_from_str(year_val) -> int:
|
|
|
|
| 120 |
if not year_val:
|
| 121 |
return 0
|
| 122 |
+
m = re.search(r"(\d{4})", str(year_val))
|
| 123 |
return int(m.group(1)) if m else 0
|
| 124 |
|
| 125 |
+
def _score_candidate(query_title: str, query_year: Optional[int], result_title: str, result_year: int) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
score = _title_similarity(query_title, result_title)
|
|
|
|
| 127 |
if query_year and result_year:
|
| 128 |
diff = abs(int(query_year) - result_year)
|
| 129 |
if diff == 0:
|
| 130 |
+
score = min(1.0, score + 0.20)
|
| 131 |
elif diff == 1:
|
| 132 |
+
score = min(1.0, score + 0.07)
|
| 133 |
+
elif diff > 2:
|
| 134 |
+
score = max(0.0, score - 0.10 * (diff - 2))
|
|
|
|
|
|
|
|
|
|
| 135 |
return score
|
| 136 |
|
|
|
|
| 137 |
def _build_query_variants(title: str, year: Optional[int] = None) -> List[str]:
|
| 138 |
+
variants: List[str] = [title]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
if year:
|
| 140 |
variants.append(f"{title} {year}")
|
| 141 |
|
| 142 |
+
stripped = re.sub(r"\s+", " ", re.sub(r"[^\w\s]", " ", title)).strip()
|
|
|
|
|
|
|
| 143 |
if stripped and stripped.lower() != title.lower():
|
| 144 |
variants.append(stripped)
|
| 145 |
if year:
|
| 146 |
variants.append(f"{stripped} {year}")
|
| 147 |
|
| 148 |
+
no_article = re.sub(r"^\b(the|a|an)\b\s+", "", title, flags=re.IGNORECASE).strip()
|
|
|
|
| 149 |
if no_article and no_article.lower() != title.lower():
|
| 150 |
variants.append(no_article)
|
| 151 |
|
|
|
|
| 152 |
seen: set = set()
|
| 153 |
+
ordered: List[str] = []
|
| 154 |
for v in variants:
|
| 155 |
+
key = v.lower()
|
| 156 |
+
if v and key not in seen:
|
| 157 |
+
seen.add(key)
|
| 158 |
+
ordered.append(v)
|
| 159 |
+
return ordered
|
| 160 |
+
|
| 161 |
+
def _first(value):
|
| 162 |
+
return value[0] if isinstance(value, list) else value
|
| 163 |
+
|
| 164 |
+
def parse_media_name(name: str) -> dict:
|
| 165 |
+
try:
|
| 166 |
+
ptn = PTN.parse(name) or {}
|
| 167 |
+
except Exception as e:
|
| 168 |
+
LOGGER.warning(f"PTN parsing failed for {name}: {e}")
|
| 169 |
+
ptn = {}
|
| 170 |
+
|
| 171 |
+
parsed = {
|
| 172 |
+
"title": ptn.get("title"),
|
| 173 |
+
"year": ptn.get("year"),
|
| 174 |
+
"season": ptn.get("season"),
|
| 175 |
+
"episode": ptn.get("episode"),
|
| 176 |
+
"quality": ptn.get("resolution"),
|
| 177 |
+
"excess": ptn.get("excess"),
|
| 178 |
+
}
|
| 179 |
|
| 180 |
+
if _guessit:
|
| 181 |
+
try:
|
| 182 |
+
g = _guessit(name)
|
| 183 |
+
parsed["title"] = parsed["title"] or _first(g.get("title"))
|
| 184 |
+
parsed["year"] = parsed["year"] or _first(g.get("year"))
|
| 185 |
+
parsed["season"] = parsed["season"] or _first(g.get("season"))
|
| 186 |
+
parsed["episode"] = parsed["episode"] or _first(g.get("episode"))
|
| 187 |
+
parsed["quality"] = parsed["quality"] or _first(g.get("screen_size"))
|
| 188 |
+
except Exception as e:
|
| 189 |
+
LOGGER.warning(f"GuessIt parsing failed for {name}: {e}")
|
| 190 |
|
| 191 |
+
return parsed
|
| 192 |
|
| 193 |
async def safe_imdb_search(title: str, type_: str, year: Optional[int] = None) -> str | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
cache_key = f"imdb::{type_}::{title}::{year}"
|
| 195 |
if cache_key in IMDB_CACHE:
|
| 196 |
return IMDB_CACHE[cache_key]
|
| 197 |
|
| 198 |
query_variants = _build_query_variants(title, year)
|
|
|
|
| 199 |
best_id: str | None = None
|
| 200 |
+
best_score = 0.0
|
| 201 |
+
best_title = ""
|
| 202 |
|
| 203 |
for query in query_variants:
|
| 204 |
try:
|
| 205 |
async with API_SEMAPHORE:
|
| 206 |
results = await search_title_multi(query=query, type=type_, limit=8)
|
|
|
|
| 207 |
for r in results:
|
| 208 |
+
score = _score_candidate(title, year, r.get("title", ""), _year_from_str(r.get("year", "")))
|
|
|
|
|
|
|
|
|
|
| 209 |
if score > best_score:
|
| 210 |
+
best_score, best_id, best_title = score, r.get("id"), r.get("title", "")
|
| 211 |
+
if best_score >= _STRONG_MATCH:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
break
|
|
|
|
| 213 |
except Exception as e:
|
| 214 |
LOGGER.warning(f"Cinemeta search variant '{query}' [{type_}] failed: {e}")
|
| 215 |
+
if best_score >= _STRONG_MATCH:
|
|
|
|
|
|
|
| 216 |
break
|
| 217 |
|
| 218 |
if best_score >= _CINEMETA_THRESHOLD and best_id:
|
| 219 |
+
LOGGER.info(f"Cinemeta match: '{title}' (year={year}) -> '{best_title}' [{best_id}] (score={best_score:.2f})")
|
|
|
|
|
|
|
|
|
|
| 220 |
IMDB_CACHE[cache_key] = best_id
|
| 221 |
return best_id
|
| 222 |
|
|
|
|
| 223 |
if best_id:
|
| 224 |
LOGGER.info(
|
| 225 |
+
f"Cinemeta low-confidence for '{title}' (year={year}, type={type_}) | "
|
| 226 |
+
f"best '{best_title}' [{best_id}] score={best_score:.2f} -> falling back to TMDb"
|
|
|
|
| 227 |
)
|
| 228 |
else:
|
| 229 |
+
LOGGER.info(f"Cinemeta returned no results for '{title}' (year={year}, type={type_}) -> falling back to TMDb")
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
IMDB_CACHE[cache_key] = None
|
| 232 |
return None
|
| 233 |
|
| 234 |
+
async def _tmdb_raw_search(title: str, media_type: str, year: Optional[int]):
|
| 235 |
+
client = get_tmdb_client()
|
| 236 |
+
async with API_SEMAPHORE:
|
| 237 |
+
if media_type == "movie":
|
| 238 |
+
results = await (client.search().movies(query=title, year=year) if year else client.search().movies(query=title))
|
| 239 |
+
if not results and year:
|
| 240 |
+
results = await client.search().movies(query=title)
|
| 241 |
+
return results
|
| 242 |
+
return await client.search().tv(query=title)
|
| 243 |
|
| 244 |
async def safe_tmdb_search(title: str, type_: str, year: Optional[int] = None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
cache_key = f"tmdb_search::{type_}::{title}::{year}"
|
| 246 |
if cache_key in TMDB_SEARCH_CACHE:
|
| 247 |
return TMDB_SEARCH_CACHE[cache_key]
|
| 248 |
|
| 249 |
try:
|
| 250 |
+
results = await _tmdb_raw_search(title, type_, year)
|
| 251 |
+
best = await _pick_best_tmdb_result(results, title, year, type_)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
if best is None and results:
|
|
|
|
| 253 |
top = results[0]
|
| 254 |
top_title = getattr(top, "title" if type_ == "movie" else "name", "?")
|
| 255 |
+
LOGGER.info(f"TMDb '{title}' (year={year}) top result '{top_title}' did not meet threshold")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
TMDB_SEARCH_CACHE[cache_key] = best
|
| 257 |
return best
|
|
|
|
| 258 |
except Exception as e:
|
| 259 |
LOGGER.error(f"TMDb search failed for '{title}' [{type_}]: {e}")
|
| 260 |
TMDB_SEARCH_CACHE[cache_key] = None
|
| 261 |
return None
|
| 262 |
|
| 263 |
+
def _tmdb_title_year(item, media_type: str) -> tuple[str, int]:
|
| 264 |
+
if media_type == "movie":
|
| 265 |
+
date = getattr(item, "release_date", None)
|
| 266 |
+
return getattr(item, "title", "") or "", getattr(date, "year", 0) if date else 0
|
| 267 |
+
date = getattr(item, "first_air_date", None)
|
| 268 |
+
return getattr(item, "name", "") or "", getattr(date, "year", 0) if date else 0
|
| 269 |
|
| 270 |
+
async def _pick_best_tmdb_result(results, query_title: str, query_year: Optional[int], media_type: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
if not results:
|
| 272 |
return None
|
| 273 |
|
| 274 |
+
scored = []
|
| 275 |
+
best_item, best_score = None, 0.0
|
|
|
|
| 276 |
for item in results:
|
| 277 |
+
r_title, r_year = _tmdb_title_year(item, media_type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
score = _score_candidate(query_title, query_year, r_title, r_year)
|
| 279 |
+
scored.append((score, item, r_year))
|
| 280 |
if score > best_score:
|
| 281 |
+
best_score, best_item = score, item
|
|
|
|
| 282 |
|
| 283 |
+
if best_score >= _STRONG_MATCH:
|
|
|
|
|
|
|
|
|
|
| 284 |
return best_item
|
| 285 |
|
| 286 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 287 |
+
for _, item, r_year in scored[:_ALT_TITLE_LOOKUPS]:
|
| 288 |
+
alt_titles = await _tmdb_alternative_titles(media_type, getattr(item, "id", None))
|
| 289 |
+
for alt in alt_titles:
|
| 290 |
+
alt_score = _score_candidate(query_title, query_year, alt, r_year)
|
| 291 |
+
if alt_score > best_score:
|
| 292 |
+
best_score, best_item = alt_score, item
|
| 293 |
+
if best_score >= _STRONG_MATCH:
|
| 294 |
+
break
|
| 295 |
+
if best_score >= _STRONG_MATCH:
|
| 296 |
+
break
|
| 297 |
|
| 298 |
+
return best_item if best_score >= _TMDB_THRESHOLD and best_item is not None else None
|
| 299 |
|
| 300 |
+
async def _tmdb_alternative_titles(media_type: str, tmdb_id) -> list[str]:
|
| 301 |
+
if not tmdb_id:
|
| 302 |
+
return []
|
| 303 |
+
cache_key = (media_type, tmdb_id)
|
| 304 |
+
if cache_key in ALT_TITLES_CACHE:
|
| 305 |
+
return ALT_TITLES_CACHE[cache_key]
|
| 306 |
+
titles: list[str] = []
|
| 307 |
try:
|
| 308 |
+
client = get_tmdb_client()
|
| 309 |
async with API_SEMAPHORE:
|
| 310 |
+
target = client.movie(tmdb_id) if media_type == "movie" else client.tv(tmdb_id)
|
| 311 |
+
alt = await target.alternative_titles()
|
| 312 |
+
entries = list(getattr(alt, "titles", None) or []) + list(getattr(alt, "results", None) or [])
|
| 313 |
+
titles = [t for t in (getattr(e, "title", "") for e in entries) if t]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
except Exception as e:
|
| 315 |
+
LOGGER.warning(f"TMDb alternative-titles fetch failed for {media_type} id={tmdb_id}: {e}")
|
| 316 |
+
ALT_TITLES_CACHE[cache_key] = titles
|
| 317 |
+
return titles
|
| 318 |
+
|
| 319 |
+
async def _tmdb_details(media_type: str, item_id):
|
| 320 |
+
cache_key = (media_type, item_id)
|
| 321 |
+
if cache_key in TMDB_DETAILS_CACHE:
|
| 322 |
+
return TMDB_DETAILS_CACHE[cache_key]
|
| 323 |
try:
|
| 324 |
+
client = get_tmdb_client()
|
| 325 |
async with API_SEMAPHORE:
|
| 326 |
+
target = client.movie(item_id) if media_type == "movie" else client.tv(item_id)
|
| 327 |
+
details = await target.details(append_to_response="external_ids,credits")
|
| 328 |
+
details.images = await target.images()
|
| 329 |
+
TMDB_DETAILS_CACHE[cache_key] = details
|
|
|
|
|
|
|
| 330 |
return details
|
| 331 |
except Exception as e:
|
| 332 |
+
LOGGER.warning(f"TMDb {media_type} details fetch failed for id={item_id}: {e}")
|
| 333 |
+
TMDB_DETAILS_CACHE[cache_key] = None
|
| 334 |
return None
|
| 335 |
|
|
|
|
| 336 |
async def _tmdb_episode_details(tv_id, season, episode):
|
| 337 |
key = (tv_id, season, episode)
|
| 338 |
if key in EPISODE_CACHE:
|
|
|
|
| 346 |
EPISODE_CACHE[key] = None
|
| 347 |
return None
|
| 348 |
|
| 349 |
+
async def _cached_imdb_detail(imdb_id: str, media_type: str):
|
| 350 |
+
cached = IMDB_CACHE.get(imdb_id)
|
| 351 |
+
if isinstance(cached, dict):
|
| 352 |
+
return cached
|
| 353 |
+
async with API_SEMAPHORE:
|
| 354 |
+
detail = await get_detail(imdb_id=imdb_id, media_type=media_type)
|
| 355 |
+
IMDB_CACHE[imdb_id] = detail
|
| 356 |
+
return detail
|
| 357 |
+
|
| 358 |
+
async def _cached_imdb_season(imdb_id: str, season, episode):
|
| 359 |
+
key = f"{imdb_id}::{season}::{episode}"
|
| 360 |
+
if key in EPISODE_CACHE:
|
| 361 |
+
return EPISODE_CACHE[key]
|
| 362 |
+
async with API_SEMAPHORE:
|
| 363 |
+
ep = await get_season(imdb_id=imdb_id, season_id=season, episode_id=episode)
|
| 364 |
+
EPISODE_CACHE[key] = ep
|
| 365 |
+
return ep
|
| 366 |
+
|
| 367 |
+
async def _tmdb_external_imdb_id(media_type: str, tmdb_id) -> str | None:
|
| 368 |
+
try:
|
| 369 |
+
details = await _tmdb_details(media_type, tmdb_id)
|
| 370 |
+
ext = getattr(details, "external_ids", None) if details else None
|
| 371 |
+
return getattr(ext, "imdb_id", None) if ext else None
|
| 372 |
+
except Exception:
|
| 373 |
+
return None
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def _extract_cast(details) -> list:
|
| 378 |
+
credits = getattr(details, "credits", None) or {}
|
| 379 |
+
cast = getattr(credits, "cast", []) or []
|
| 380 |
+
return [getattr(c, "name", None) or getattr(c, "original_name", None) for c in cast]
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _format_runtime(minutes) -> str:
|
| 384 |
+
return f"{minutes} min" if minutes else ""
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# Build the indexer movie payload from a TMDb details object.
|
| 388 |
+
def _build_tmdb_movie_payload(movie, quality, encoded_string) -> dict:
|
| 389 |
+
release = getattr(movie, "release_date", None)
|
| 390 |
+
return {
|
| 391 |
+
"tmdb_id": movie.id,
|
| 392 |
+
"imdb_id": getattr(getattr(movie, "external_ids", None), "imdb_id", None),
|
| 393 |
+
"title": movie.title,
|
| 394 |
+
"year": getattr(release, "year", 0) if release else 0,
|
| 395 |
+
"rate": getattr(movie, "vote_average", 0) or 0,
|
| 396 |
+
"description": movie.overview or "",
|
| 397 |
+
"poster": format_tmdb_image(movie.poster_path),
|
| 398 |
+
"backdrop": format_tmdb_image(movie.backdrop_path, "original"),
|
| 399 |
+
"logo": get_tmdb_logo(getattr(movie, "images", None)),
|
| 400 |
+
"cast": _extract_cast(movie),
|
| 401 |
+
"runtime": str(_format_runtime(getattr(movie, "runtime", None))),
|
| 402 |
+
"media_type": "movie",
|
| 403 |
+
"genres": [g.name for g in (movie.genres or [])],
|
| 404 |
+
"quality": quality,
|
| 405 |
+
"encoded_string": encoded_string,
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
# Build the indexer TV payload from TMDb series + episode details.
|
| 410 |
+
def _build_tmdb_tv_payload(tv, ep, season, episode, quality, encoded_string) -> dict:
|
| 411 |
+
first_air = getattr(tv, "first_air_date", None)
|
| 412 |
+
series_runtime = tv.episode_run_time[0] if getattr(tv, "episode_run_time", None) else None
|
| 413 |
+
runtime = _format_runtime((getattr(ep, "runtime", None) if ep else None) or series_runtime)
|
| 414 |
+
fallback_ep_title = f"S{season:02d}E{episode:02d}"
|
| 415 |
+
return {
|
| 416 |
+
"tmdb_id": tv.id,
|
| 417 |
+
"imdb_id": getattr(getattr(tv, "external_ids", None), "imdb_id", None),
|
| 418 |
+
"title": tv.name,
|
| 419 |
+
"year": getattr(first_air, "year", 0) if first_air else 0,
|
| 420 |
+
"rate": getattr(tv, "vote_average", 0) or 0,
|
| 421 |
+
"description": tv.overview or "",
|
| 422 |
+
"poster": format_tmdb_image(tv.poster_path),
|
| 423 |
+
"backdrop": format_tmdb_image(tv.backdrop_path, "original"),
|
| 424 |
+
"logo": get_tmdb_logo(getattr(tv, "images", None)),
|
| 425 |
+
"genres": [g.name for g in (tv.genres or [])],
|
| 426 |
+
"media_type": "tv",
|
| 427 |
+
"cast": _extract_cast(tv),
|
| 428 |
+
"runtime": str(runtime),
|
| 429 |
+
"season_number": season,
|
| 430 |
+
"episode_number": episode,
|
| 431 |
+
"episode_title": getattr(ep, "name", fallback_ep_title) if ep else fallback_ep_title,
|
| 432 |
+
"episode_backdrop": format_tmdb_image(getattr(ep, "still_path", None), "original") if ep else "",
|
| 433 |
+
"episode_overview": getattr(ep, "overview", "") if ep else "",
|
| 434 |
+
"episode_released": ep.air_date.strftime("%Y-%m-%dT05:00:00.000Z") if (ep and getattr(ep, "air_date", None)) else "",
|
| 435 |
+
"quality": quality,
|
| 436 |
+
"encoded_string": encoded_string,
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
# Build the indexer movie payload from Cinemeta/IMDb details.
|
| 441 |
+
def _build_imdb_movie_payload(imdb, imdb_id, title, quality, encoded_string) -> dict:
|
| 442 |
+
images = format_imdb_images(imdb_id)
|
| 443 |
+
return {
|
| 444 |
+
"tmdb_id": imdb.get("moviedb_id") or (imdb_id.replace("tt", "") if imdb_id else None),
|
| 445 |
+
"imdb_id": imdb_id,
|
| 446 |
+
"title": imdb.get("title", title),
|
| 447 |
+
"year": imdb.get("releaseDetailed", {}).get("year", 0),
|
| 448 |
+
"rate": imdb.get("rating", {}).get("star", 0),
|
| 449 |
+
"description": imdb.get("plot", ""),
|
| 450 |
+
"poster": images["poster"],
|
| 451 |
+
"backdrop": images["backdrop"],
|
| 452 |
+
"logo": images["logo"],
|
| 453 |
+
"cast": imdb.get("cast", []),
|
| 454 |
+
"runtime": str(imdb.get("runtime") or ""),
|
| 455 |
+
"media_type": "movie",
|
| 456 |
+
"genres": imdb.get("genre", []),
|
| 457 |
+
"quality": quality,
|
| 458 |
+
"encoded_string": encoded_string,
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
# Build the indexer TV payload from Cinemeta/IMDb series + episode details.
|
| 463 |
+
def _build_imdb_tv_payload(imdb, ep, imdb_id, title, season, episode, quality, encoded_string) -> dict:
|
| 464 |
+
images = format_imdb_images(imdb_id)
|
| 465 |
+
return {
|
| 466 |
+
"tmdb_id": imdb.get("moviedb_id") or (imdb_id.replace("tt", "") if imdb_id else None),
|
| 467 |
+
"imdb_id": imdb_id,
|
| 468 |
+
"title": imdb.get("title", title),
|
| 469 |
+
"year": imdb.get("releaseDetailed", {}).get("year", 0),
|
| 470 |
+
"rate": imdb.get("rating", {}).get("star", 0),
|
| 471 |
+
"description": imdb.get("plot", ""),
|
| 472 |
+
"poster": images["poster"],
|
| 473 |
+
"backdrop": images["backdrop"],
|
| 474 |
+
"logo": images["logo"],
|
| 475 |
+
"cast": imdb.get("cast", []),
|
| 476 |
+
"runtime": str(imdb.get("runtime") or ""),
|
| 477 |
+
"genres": imdb.get("genre", []),
|
| 478 |
+
"media_type": "tv",
|
| 479 |
+
"season_number": season,
|
| 480 |
+
"episode_number": episode,
|
| 481 |
+
"episode_title": ep.get("title", f"S{season:02d}E{episode:02d}"),
|
| 482 |
+
"episode_backdrop": ep.get("image", ""),
|
| 483 |
+
"episode_overview": ep.get("plot", ""),
|
| 484 |
+
"episode_released": str(ep.get("released", "")),
|
| 485 |
+
"quality": quality,
|
| 486 |
+
"encoded_string": encoded_string,
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
|
| 490 |
+
# ----------------- Main entry-point -----------------
|
|
|
|
|
|
|
| 491 |
|
| 492 |
+
# Parse a filename/caption and resolve full movie or TV metadata for the indexer.
|
| 493 |
async def metadata(filename: str, channel: int, msg_id, override_id: str = None) -> dict | None:
|
| 494 |
try:
|
| 495 |
+
parsed = parse_media_name(filename)
|
| 496 |
except Exception as e:
|
| 497 |
+
LOGGER.error(f"Parsing failed for {filename}: {e}\n{traceback.format_exc()}")
|
| 498 |
return None
|
| 499 |
|
| 500 |
+
if _MULTIPART_RE.search(filename):
|
| 501 |
+
LOGGER.info(f"Skipping {filename}: split video file not meant to be combined in Stremio")
|
|
|
|
|
|
|
|
|
|
| 502 |
return None
|
| 503 |
|
| 504 |
+
excess = parsed.get("excess")
|
| 505 |
+
if excess and any("combined" in item.lower() for item in excess):
|
|
|
|
| 506 |
LOGGER.info(f"Skipping {filename}: contains 'combined'")
|
| 507 |
return None
|
| 508 |
|
|
|
|
| 513 |
season = parsed.get("season")
|
| 514 |
episode = parsed.get("episode")
|
| 515 |
year = parsed.get("year")
|
| 516 |
+
quality = parsed.get("quality")
|
| 517 |
|
| 518 |
if isinstance(season, list) or isinstance(episode, list):
|
| 519 |
LOGGER.warning(f"Invalid season/episode format for {filename}: {parsed}")
|
|
|
|
| 528 |
LOGGER.info(f"No title parsed from: {filename} (parsed={parsed})")
|
| 529 |
return None
|
| 530 |
|
| 531 |
+
default_id = _resolve_default_id(override_id, filename)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
try:
|
| 534 |
+
encoded_string = await encode_string({"chat_id": channel, "msg_id": msg_id})
|
| 535 |
except Exception:
|
| 536 |
encoded_string = None
|
| 537 |
|
| 538 |
+
group_key = f"{channel}:{quality}:{split_info[0]}" if split_info else None
|
|
|
|
|
|
|
| 539 |
|
| 540 |
try:
|
| 541 |
if season and episode:
|
|
|
|
| 553 |
return None
|
| 554 |
|
| 555 |
|
| 556 |
+
# Pick a default id from the override, the global setting, then the filename itself.
|
| 557 |
+
def _resolve_default_id(override_id, filename) -> str | None:
|
| 558 |
+
for source in (override_id, getattr(Backend, "USE_DEFAULT_ID", None), filename):
|
| 559 |
+
if not source:
|
| 560 |
+
continue
|
| 561 |
+
try:
|
| 562 |
+
found = extract_default_id(source) or (override_id if source is override_id else None)
|
| 563 |
+
except Exception:
|
| 564 |
+
found = None
|
| 565 |
+
if found:
|
| 566 |
+
return found
|
| 567 |
+
return None
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
# ----------------- TV metadata -----------------
|
| 571 |
|
| 572 |
+
# Resolve TV metadata, preferring Cinemeta and falling back to TMDb.
|
| 573 |
async def fetch_tv_metadata(title, season, episode, encoded_string, year=None, quality=None, default_id=None) -> dict | None:
|
| 574 |
+
imdb_id, tmdb_id, explicit_imdb_id, use_tmdb = _split_default_id(default_id)
|
|
|
|
| 575 |
imdb_tv = None
|
| 576 |
imdb_ep = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
|
|
|
|
|
|
|
|
|
|
| 578 |
if not imdb_id and not tmdb_id:
|
| 579 |
imdb_id = await safe_imdb_search(title, "tvSeries", year)
|
| 580 |
use_tmdb = not bool(imdb_id)
|
| 581 |
|
|
|
|
|
|
|
|
|
|
| 582 |
if imdb_id and not use_tmdb:
|
| 583 |
try:
|
| 584 |
+
imdb_tv = await _cached_imdb_detail(imdb_id, "tvSeries")
|
| 585 |
+
imdb_ep = await _cached_imdb_season(imdb_id, season, episode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
except Exception as e:
|
| 587 |
+
LOGGER.warning(f"IMDb TV fetch failed [{imdb_id}] -> {e}")
|
| 588 |
+
imdb_tv = imdb_ep = None
|
|
|
|
| 589 |
use_tmdb = True
|
| 590 |
|
| 591 |
+
# Guard against Cinemeta returning a wrong hit (skipped for user-supplied ids).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
if imdb_tv and not use_tmdb and not explicit_imdb_id:
|
| 593 |
+
sim = _title_similarity(title, imdb_tv.get("title", ""))
|
|
|
|
| 594 |
if sim < _CINEMETA_THRESHOLD:
|
| 595 |
+
LOGGER.info(f"IMDb TV title mismatch for '{title}': got '{imdb_tv.get('title', '')}' (sim={sim:.2f}) -> TMDb")
|
|
|
|
|
|
|
|
|
|
| 596 |
imdb_tv = None
|
| 597 |
use_tmdb = True
|
| 598 |
|
| 599 |
+
if use_tmdb or not imdb_tv:
|
| 600 |
+
LOGGER.info(f"No valid Cinemeta TV data for '{title}' S{season:02d}E{episode:02d} -> using TMDb")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
if not tmdb_id:
|
| 602 |
+
tmdb_search = await safe_tmdb_search(title, "tv", year) or (await safe_tmdb_search(title, "tv", None) if year else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
if not tmdb_search:
|
| 604 |
+
LOGGER.info(f"No TMDb TV result for '{title}' S{season:02d}E{episode:02d} (year={year})")
|
|
|
|
|
|
|
|
|
|
| 605 |
return None
|
| 606 |
tmdb_id = tmdb_search.id
|
| 607 |
|
| 608 |
+
tv = await _tmdb_details("tv", tmdb_id)
|
| 609 |
if not tv:
|
| 610 |
LOGGER.info(f"TMDb TV details failed for id={tmdb_id} ('{title}')")
|
| 611 |
return None
|
|
|
|
| 612 |
ep = await _tmdb_episode_details(tmdb_id, season, episode)
|
| 613 |
+
return _build_tmdb_tv_payload(tv, ep, season, episode, quality, encoded_string)
|
| 614 |
|
| 615 |
+
return _build_imdb_tv_payload(imdb_tv, imdb_ep or {}, imdb_id, title, season, episode, quality, encoded_string)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
|
| 618 |
+
# ----------------- Movie metadata -----------------
|
| 619 |
|
| 620 |
+
# Resolve movie metadata, preferring Cinemeta and falling back to TMDb.
|
|
|
|
|
|
|
|
|
|
| 621 |
async def fetch_movie_metadata(title, encoded_string, year=None, quality=None, default_id=None) -> dict | None:
|
| 622 |
+
imdb_id, tmdb_id, explicit_imdb_id, use_tmdb = _split_default_id(default_id)
|
|
|
|
| 623 |
imdb_details = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
|
|
|
|
|
|
|
|
|
|
| 625 |
if not imdb_id and not tmdb_id:
|
| 626 |
imdb_id = await safe_imdb_search(title, "movie", year)
|
| 627 |
use_tmdb = not bool(imdb_id)
|
| 628 |
|
|
|
|
|
|
|
|
|
|
| 629 |
if imdb_id and not use_tmdb:
|
| 630 |
try:
|
| 631 |
+
imdb_details = await _cached_imdb_detail(imdb_id, "movie")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
except Exception as e:
|
| 633 |
+
LOGGER.warning(f"IMDb movie fetch failed [{title}] -> {e}")
|
| 634 |
imdb_details = None
|
| 635 |
use_tmdb = True
|
| 636 |
|
| 637 |
+
# Guard against Cinemeta returning a wrong hit (skipped for user-supplied ids).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
if imdb_details and not use_tmdb and not explicit_imdb_id:
|
| 639 |
+
sim = _title_similarity(title, imdb_details.get("title", ""))
|
|
|
|
| 640 |
if sim < _CINEMETA_THRESHOLD:
|
| 641 |
+
LOGGER.info(f"IMDb movie title mismatch for '{title}': got '{imdb_details.get('title', '')}' (sim={sim:.2f}) -> TMDb")
|
|
|
|
|
|
|
|
|
|
| 642 |
imdb_details = None
|
| 643 |
use_tmdb = True
|
| 644 |
|
| 645 |
+
if use_tmdb or not imdb_details:
|
| 646 |
+
LOGGER.info(f"No valid Cinemeta movie data for '{title}' (year={year}) -> using TMDb")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
if not tmdb_id:
|
| 648 |
+
tmdb_result = await safe_tmdb_search(title, "movie", year) or (await safe_tmdb_search(title, "movie", None) if year else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
if not tmdb_result:
|
| 650 |
+
LOGGER.info(f"No TMDb movie found for '{title}' (year={year})")
|
|
|
|
|
|
|
|
|
|
| 651 |
return None
|
| 652 |
tmdb_id = tmdb_result.id
|
| 653 |
|
| 654 |
+
movie = await _tmdb_details("movie", tmdb_id)
|
| 655 |
if not movie:
|
| 656 |
LOGGER.info(f"TMDb movie details failed for id={tmdb_id} ('{title}')")
|
| 657 |
return None
|
| 658 |
+
return _build_tmdb_movie_payload(movie, quality, encoded_string)
|
| 659 |
|
| 660 |
+
return _build_imdb_movie_payload(imdb_details, imdb_id, title, quality, encoded_string)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
|
|
|
|
|
|
|
| 662 |
|
| 663 |
+
# ----------------- Candidate search (used by the /set command UI) -----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
|
| 665 |
+
# Build a single candidate dict for the picker UI.
|
| 666 |
+
def _candidate_entry(source, title, year, imdb_id, tmdb_id, poster, backdrop, subtitle) -> dict:
|
| 667 |
return {
|
| 668 |
+
"source": source,
|
| 669 |
+
"title": title or "",
|
| 670 |
+
"year": year or "",
|
| 671 |
"imdb_id": imdb_id,
|
| 672 |
+
"tmdb_id": tmdb_id,
|
| 673 |
+
"poster": poster,
|
| 674 |
+
"backdrop": backdrop,
|
| 675 |
+
"subtitle": subtitle,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
}
|
| 677 |
|
| 678 |
|
| 679 |
+
# Search IMDb + TMDb for picker candidates of the given media type.
|
| 680 |
+
async def _search_candidates(query: str, media_type: str, year: int | None = None, limit: int = 8) -> list[dict]:
|
|
|
|
|
|
|
|
|
|
| 681 |
query = (query or "").strip()
|
| 682 |
if not query:
|
| 683 |
return []
|
| 684 |
|
| 685 |
+
imdb_type = "movie" if media_type == "movie" else "tvSeries"
|
| 686 |
results: list[dict] = []
|
| 687 |
seen: set[tuple[str, str]] = set()
|
| 688 |
|
| 689 |
try:
|
| 690 |
+
imdb_hit = await search_title(query=query, type=imdb_type)
|
| 691 |
+
if imdb_hit and imdb_hit.get("id"):
|
| 692 |
+
seen.add(("imdb", imdb_hit["id"]))
|
| 693 |
+
results.append(_candidate_entry(
|
| 694 |
+
"imdb", imdb_hit.get("title", ""), imdb_hit.get("year", ""),
|
| 695 |
+
imdb_hit.get("id"), imdb_hit.get("moviedb_id"),
|
| 696 |
+
imdb_hit.get("poster", ""), "", "IMDb / Cinemeta",
|
| 697 |
+
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 698 |
except Exception as e:
|
| 699 |
+
LOGGER.warning(f"IMDb {media_type} candidate search failed for '{query}': {e}")
|
| 700 |
|
| 701 |
try:
|
| 702 |
+
tmdb_results = await _tmdb_raw_search(query, media_type, year if media_type == "movie" else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
for item in (tmdb_results or [])[:limit]:
|
| 704 |
tmdb_id = getattr(item, "id", None)
|
| 705 |
+
if not tmdb_id or ("tmdb", str(tmdb_id)) in seen:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
continue
|
| 707 |
+
seen.add(("tmdb", str(tmdb_id)))
|
| 708 |
+
imdb_id = await _tmdb_external_imdb_id(media_type, tmdb_id)
|
| 709 |
+
r_title, r_year = _tmdb_title_year(item, media_type)
|
| 710 |
+
results.append(_candidate_entry(
|
| 711 |
+
"tmdb", r_title, r_year or "", imdb_id, tmdb_id,
|
| 712 |
+
format_tmdb_image(getattr(item, "poster_path", None)),
|
| 713 |
+
format_tmdb_image(getattr(item, "backdrop_path", None), "original"),
|
| 714 |
+
"TMDb",
|
| 715 |
+
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
except Exception as e:
|
| 717 |
+
LOGGER.warning(f"TMDb {media_type} candidate search failed for '{query}': {e}")
|
| 718 |
|
| 719 |
return results[:limit]
|
| 720 |
|
| 721 |
|
| 722 |
+
# Search movie candidates for the picker UI.
|
| 723 |
+
async def search_movie_candidates(query: str, year: int | None = None, limit: int = 8) -> list[dict]:
|
| 724 |
+
return await _search_candidates(query, "movie", year, limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
|
| 726 |
|
| 727 |
+
# Search TV candidates for the picker UI.
|
| 728 |
+
async def search_tv_candidates(query: str, limit: int = 8) -> list[dict]:
|
| 729 |
+
return await _search_candidates(query, "tv", None, limit)
|
| 730 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
|
| 732 |
+
# ----------------- Manual /set command helpers -----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
|
| 734 |
+
# Reshape an indexer payload into the manual-rescan response shape.
|
| 735 |
+
def _to_selection_payload(data: dict, media_type: str) -> dict:
|
| 736 |
return {
|
| 737 |
"tmdb_id": data.get("tmdb_id"),
|
| 738 |
"imdb_id": data.get("imdb_id"),
|
|
|
|
| 746 |
"genres": data.get("genres", []),
|
| 747 |
"cast": data.get("cast", []),
|
| 748 |
"runtime": data.get("runtime"),
|
| 749 |
+
"media_type": media_type,
|
| 750 |
}
|
| 751 |
|
| 752 |
|
| 753 |
+
# Fetch full movie metadata for a manually selected id.
|
| 754 |
+
async def fetch_selected_movie_metadata(selected_id: str) -> dict | None:
|
| 755 |
selected_id = str(selected_id).strip()
|
| 756 |
if not selected_id:
|
| 757 |
return None
|
| 758 |
+
data = await fetch_movie_metadata(
|
| 759 |
+
title="manual-rescan", encoded_string=None, year=None, quality=None, default_id=selected_id
|
| 760 |
+
)
|
| 761 |
+
return _to_selection_payload(data, "movie") if data else None
|
| 762 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
|
| 764 |
+
# Fetch full TV metadata for a manually selected id.
|
| 765 |
+
async def fetch_selected_tv_metadata(selected_id: str) -> dict | None:
|
| 766 |
+
selected_id = str(selected_id).strip()
|
| 767 |
+
imdb_id, tmdb_id, _, use_tmdb = _split_default_id(selected_id)
|
| 768 |
+
if not imdb_id and not tmdb_id:
|
|
|
|
| 769 |
return None
|
| 770 |
|
| 771 |
+
imdb_tv = None
|
| 772 |
if imdb_id and not use_tmdb:
|
| 773 |
try:
|
| 774 |
imdb_tv = await get_detail(imdb_id=imdb_id, media_type="tvSeries")
|
|
|
|
| 782 |
tmdb_id = int(imdb_tv["moviedb_id"])
|
| 783 |
except Exception:
|
| 784 |
tmdb_id = None
|
|
|
|
| 785 |
if not tmdb_id:
|
| 786 |
return None
|
| 787 |
|
| 788 |
+
tv = await _tmdb_details("tv", tmdb_id)
|
| 789 |
if not tv:
|
| 790 |
return None
|
| 791 |
+
first_air = getattr(tv, "first_air_date", None)
|
| 792 |
+
runtime = _format_runtime(tv.episode_run_time[0] if getattr(tv, "episode_run_time", None) else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
return {
|
| 794 |
"tmdb_id": tv.id,
|
| 795 |
"imdb_id": getattr(getattr(tv, "external_ids", None), "imdb_id", None),
|
| 796 |
"title": tv.name,
|
| 797 |
+
"release_year": getattr(first_air, "year", 0) if first_air else 0,
|
| 798 |
"rating": getattr(tv, "vote_average", 0) or 0,
|
| 799 |
"description": tv.overview or "",
|
| 800 |
"poster": format_tmdb_image(tv.poster_path),
|
| 801 |
"backdrop": format_tmdb_image(tv.backdrop_path, "original"),
|
| 802 |
"logo": get_tmdb_logo(getattr(tv, "images", None)),
|
| 803 |
"genres": [g.name for g in (tv.genres or [])],
|
| 804 |
+
"cast": _extract_cast(tv),
|
| 805 |
"runtime": str(runtime),
|
| 806 |
"media_type": "tv",
|
| 807 |
}
|
README.md
CHANGED
|
@@ -46,16 +46,10 @@
|
|
| 46 |
|
| 47 |
* [🔧 Configuration Guide](#-configuration-guide)
|
| 48 |
|
| 49 |
-
* [
|
| 50 |
-
* [
|
| 51 |
-
* [
|
| 52 |
-
* [
|
| 53 |
-
* [💳 Subscription System](#-subscription-system)
|
| 54 |
-
* [🧠 Admin Panel](#-admin-panel)
|
| 55 |
-
* [🔄 Update Settings](#-update-settings)
|
| 56 |
-
* [🗄️ Multi-Database Support](#️-multi-database-support)
|
| 57 |
-
* [🔑 Multi-Token System](#-multi-token-system)
|
| 58 |
-
* [🔍 Global Search Requirements](#-global-search-requirements)
|
| 59 |
|
| 60 |
* [🚀 Deployment Guide](#-deployment-guide)
|
| 61 |
|
|
@@ -63,10 +57,10 @@
|
|
| 63 |
* [🐙 Heroku Guide](#-heroku-guide)
|
| 64 |
* [🐳 VPS Guide (Recommended)](#-vps-guide-recommended)
|
| 65 |
|
| 66 |
-
* [📺 Setting Up
|
| 67 |
|
| 68 |
-
* [
|
| 69 |
-
* [
|
| 70 |
|
| 71 |
* [🏅 Contributors](#-contributors)
|
| 72 |
|
|
@@ -248,7 +242,7 @@ The `/set` command is used to manually upload a specific Movie or TV show to you
|
|
| 248 |
**Example:**
|
| 249 |
|
| 250 |
```
|
| 251 |
-
/set https://
|
| 252 |
```
|
| 253 |
|
| 254 |
**Steps:**
|
|
@@ -263,117 +257,200 @@ The `/set` command is used to manually upload a specific Movie or TV show to you
|
|
| 263 |
|
| 264 |
# 🔧 Configuration Guide
|
| 265 |
|
| 266 |
-
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
---
|
| 269 |
|
| 270 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
|
| 275 |
-
|
|
| 276 |
-
|
|
| 277 |
-
|
|
| 278 |
-
|
|
| 279 |
-
|
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
---
|
| 283 |
|
| 284 |
-
#
|
| 285 |
|
| 286 |
-
|
| 287 |
|
| 288 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
| **`TMDB_API`** | TMDb API key used to fetch movie and TV show metadata automatically. |
|
| 296 |
-
| **`GLOBAL_SEARCH`** | Enables searching media across configured Global Search channels. Requires a valid `USER_SESSION_STRING`. |
|
| 297 |
-
| **`GLOBAL_SEARCH_CHANNELS`** | List of channel IDs included in Global Search. Searches are restricted to these channels only. |
|
| 298 |
|
| 299 |
-
|
|
|
|
|
|
|
| 300 |
|
| 301 |
-
##
|
|
|
|
| 302 |
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
-
|
| 309 |
|
| 310 |
-
##
|
|
|
|
| 311 |
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
| **`SUBSCRIPTION`** | Enables subscription-based access control for streams. |
|
| 315 |
-
| **`SUBSCRIPTION_GROUP_ID`** | Telegram group/channel where approved subscribers are invited. |
|
| 316 |
-
| **`APPROVER_IDS`** | Telegram user IDs allowed to approve or reject subscription requests. |
|
| 317 |
-
| **`SUBSCRIPTION_URL`** | Bot or payment URL displayed to users when their subscription has expired. |
|
| 318 |
|
| 319 |
---
|
| 320 |
|
| 321 |
-
##
|
| 322 |
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
| **`ADMIN_USERNAME`** | Username used to access the Web Admin Panel. |
|
| 326 |
-
| **`ADMIN_PASSWORD`** | Password used to access the Web Admin Panel. Change the default values immediately for security. |
|
| 327 |
|
| 328 |
-
|
| 329 |
|
| 330 |
-
##
|
| 331 |
|
| 332 |
-
|
| 333 |
-
| :-------------------- | :-------------------------------------------- |
|
| 334 |
-
| **`UPSTREAM_REPO`** | Git repository URL used by the update system. |
|
| 335 |
-
| **`UPSTREAM_BRANCH`** | Branch that the updater should track. |
|
| 336 |
|
| 337 |
-
|
| 338 |
|
| 339 |
-
|
| 340 |
|
| 341 |
-
|
| 342 |
-
| :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
|
| 343 |
-
| **`EXTRA_DATABASES`** | Additional MongoDB databases used for load balancing, scalability, and redundancy. Can be added or removed directly from the Web Panel. |
|
| 344 |
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
|
| 347 |
-
|
|
|
|
| 348 |
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
|
| 353 |
-
|
| 354 |
|
| 355 |
-
|
| 356 |
|
| 357 |
-
|
| 358 |
|
| 359 |
-
|
| 360 |
-
2. Add each bot as an admin in all AUTH CHANNELS.
|
| 361 |
-
3. Add their tokens in the **Multi Token** section of the Web Panel.
|
| 362 |
-
4. The system automatically distributes traffic across all available bots.
|
| 363 |
|
| 364 |
---
|
| 365 |
|
| 366 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
|
| 368 |
-
|
| 369 |
|
| 370 |
-
*
|
| 371 |
-
* Enable **Global Search** from the Web Panel.
|
| 372 |
-
* Add one or more **Global Search Channels**.
|
| 373 |
-
* Searches will only be performed within the configured Global Search channels.
|
| 374 |
|
| 375 |
-
|
| 376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
|
| 378 |
---
|
| 379 |
|
|
@@ -662,112 +739,39 @@ sudo apt install caddy
|
|
| 662 |
➡️ `https://your-domain.com`
|
| 663 |
|
| 664 |
|
| 665 |
-
# 📺 Setting
|
|
|
|
|
|
|
| 666 |
|
| 667 |
-
|
| 668 |
|
| 669 |
-
##
|
| 670 |
|
| 671 |
-
Download
|
| 672 |
-
👉 [https://www.stremio.com/downloads](https://www.stremio.com/downloads)
|
| 673 |
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
| 675 |
|
| 676 |
-
|
| 677 |
|
| 678 |
-
##
|
| 679 |
|
| 680 |
-
1.
|
| 681 |
-
2.
|
| 682 |
-
3. In the search bar, paste the appropriate addon URL:
|
| 683 |
|
| 684 |
| Deployment Method | Addon URL |
|
| 685 |
| :--- | :--- |
|
| 686 |
| **Heroku** | `https://<your-heroku-app>.herokuapp.com/stremio/manifest.json` |
|
| 687 |
| **Custom Domain** | `https://<your-domain>/stremio/manifest.json` |
|
| 688 |
|
|
|
|
| 689 |
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
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.
|
| 693 |
-
|
| 694 |
-
### 1️⃣ Step 1: Uninstall Other Addons
|
| 695 |
-
|
| 696 |
-
1. Go to the **Addon Section** in the Stremio App.
|
| 697 |
-
2. **Uninstall all addons** except your Telegram Stremio Media Server.
|
| 698 |
-
3. Attempt to remove **Cinemeta**. If Stremio prevents it, proceed to Step 2.
|
| 699 |
-
|
| 700 |
-
### 2️⃣ Step 2: Remove “Cinemeta” Protection
|
| 701 |
-
|
| 702 |
-
1. Log in to your **Stremio account** using **Chrome or Chromium-based browser** :
|
| 703 |
-
👉 [https://web.stremio.com/](https://web.stremio.com/)
|
| 704 |
-
2. Once logged in, open your **browser console** (`Ctrl + Shift + J` on Windows/Linux or `Cmd + Option + J` on macOS).
|
| 705 |
-
3. Copy and paste the code below into the console and press **Enter**:
|
| 706 |
-
|
| 707 |
-
<!-- end list -->
|
| 708 |
-
|
| 709 |
-
```js
|
| 710 |
-
(function() {
|
| 711 |
-
|
| 712 |
-
const token = JSON.parse(localStorage.getItem("profile")).auth.key;
|
| 713 |
-
|
| 714 |
-
const requestData = {
|
| 715 |
-
type: "AddonCollectionGet",
|
| 716 |
-
authKey: token,
|
| 717 |
-
update: true
|
| 718 |
-
};
|
| 719 |
-
|
| 720 |
-
fetch('https://api.strem.io/api/addonCollectionGet', {
|
| 721 |
-
method: 'POST',
|
| 722 |
-
body: JSON.stringify(requestData)
|
| 723 |
-
})
|
| 724 |
-
.then(response => response.json())
|
| 725 |
-
.then(data => {
|
| 726 |
-
|
| 727 |
-
if (data && data.result) {
|
| 728 |
-
|
| 729 |
-
let result = JSON.stringify(data.result).substring(1).replace(/"protected":true/g, '"protected":false').replace('"idPrefixes":["tmdb:"]', '"idPrefixes":["tmdb:","tt"]');
|
| 730 |
-
|
| 731 |
-
const index = result.indexOf("}}],");
|
| 732 |
-
|
| 733 |
-
if (index !== -1) {
|
| 734 |
-
result = result.substring(0, index + 3) + "}";
|
| 735 |
-
}
|
| 736 |
-
|
| 737 |
-
let addons = '{"type":"AddonCollectionSet","authKey":"' + token + '",' + result;
|
| 738 |
-
|
| 739 |
-
fetch('https://api.strem.io/api/addonCollectionSet', {
|
| 740 |
-
method: 'POST',
|
| 741 |
-
body: addons
|
| 742 |
-
})
|
| 743 |
-
.then(response => response.text())
|
| 744 |
-
.then(data => {
|
| 745 |
-
console.log('Success:', data);
|
| 746 |
-
})
|
| 747 |
-
.catch((error) => {
|
| 748 |
-
console.error('Error:', error);
|
| 749 |
-
});
|
| 750 |
-
|
| 751 |
-
} else {
|
| 752 |
-
console.error('Error:', error);
|
| 753 |
-
}
|
| 754 |
-
})
|
| 755 |
-
.catch((error) => {
|
| 756 |
-
console.error('Erro:', error);
|
| 757 |
-
});
|
| 758 |
-
})();
|
| 759 |
-
```
|
| 760 |
-
|
| 761 |
-
### 3️⃣ Step 3: Confirm Success
|
| 762 |
-
|
| 763 |
-
- Wait until you see this message in the console:
|
| 764 |
-
```
|
| 765 |
-
Success: {"result":{"success":true}}
|
| 766 |
-
```
|
| 767 |
-
- Refresh the page (**F5**). You will now be able to **remove Cinemeta** from your addons list.
|
| 768 |
|
| 769 |
|
| 770 |
-
## 🏅
|
| 771 |
|
| 772 |
|<img width="80" src="https://avatars.githubusercontent.com/u/113664541">|<img width="80" src="https://avatars.githubusercontent.com/u/13152917">|<img width="80" src="https://avatars.githubusercontent.com/u/14957082">|<img width="80" src="https://raw.githubusercontent.com/vflixa1prime/Readme/main/VFlixPRime.png">|
|
| 773 |
|:---:|:---:|:---:|:---:|
|
|
|
|
| 46 |
|
| 47 |
* [🔧 Configuration Guide](#-configuration-guide)
|
| 48 |
|
| 49 |
+
* [🪜 Step 1: Create Your config.env File](#-step-1-create-your-configenv-file)
|
| 50 |
+
* [🔑 Step 2: How to Get Each Value](#-step-2-how-to-get-each-value)
|
| 51 |
+
* [📱 Step 3: Generate Your Telegram Session String](#-step-3-generate-your-telegram-session-string)
|
| 52 |
+
* [🧩 Step 4: Configure Everything Else (Web Settings Page)](#-step-4-configure-everything-else-web-settings-page)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
* [🚀 Deployment Guide](#-deployment-guide)
|
| 55 |
|
|
|
|
| 57 |
* [🐙 Heroku Guide](#-heroku-guide)
|
| 58 |
* [🐳 VPS Guide (Recommended)](#-vps-guide-recommended)
|
| 59 |
|
| 60 |
+
* [📺 Setting Up Your App (Nuvio Recommended)](#-setting-up-your-app-nuvio-recommended)
|
| 61 |
|
| 62 |
+
* [📥 Install Nuvio](#-step-1-install-nuvio)
|
| 63 |
+
* [🌐 Add the Addon](#-step-2-add-the-addon)
|
| 64 |
|
| 65 |
* [🏅 Contributors](#-contributors)
|
| 66 |
|
|
|
|
| 242 |
**Example:**
|
| 243 |
|
| 244 |
```
|
| 245 |
+
/set https://www.imdb.com/title/tt0468569/
|
| 246 |
```
|
| 247 |
|
| 248 |
**Steps:**
|
|
|
|
| 257 |
|
| 258 |
# 🔧 Configuration Guide
|
| 259 |
|
| 260 |
+
> 😌 **Don’t worry — setup is easier than it looks.**
|
| 261 |
+
> You only fill in **6 values once** inside a single file called `config.env`. Everything else (TMDB key, channels, admin login, subscriptions, proxy…) is configured later from a friendly **Web Settings page** — no code, no restarts.
|
| 262 |
+
|
| 263 |
+
Think of configuration as **two simple layers**:
|
| 264 |
+
|
| 265 |
+
| Layer | Where | When you set it | What goes here |
|
| 266 |
+
| :--- | :--- | :--- | :--- |
|
| 267 |
+
| 🧱 **Startup** | `config.env` file | Once, before first launch | The core credentials needed to boot |
|
| 268 |
+
| 🎛️ **Runtime** | **Web Settings page** | Anytime, after launch | Everything else — saved to the database, applied live |
|
| 269 |
|
| 270 |
---
|
| 271 |
|
| 272 |
+
## 🪜 Step 1: Create Your config.env File
|
| 273 |
+
|
| 274 |
+
After cloning the project, copy the sample file and open it for editing:
|
| 275 |
+
|
| 276 |
+
```bash
|
| 277 |
+
cp sample_config.env config.env
|
| 278 |
+
nano config.env
|
| 279 |
+
```
|
| 280 |
|
| 281 |
+
Fill in these values:
|
| 282 |
+
|
| 283 |
+
| Variable | Required | What it is |
|
| 284 |
+
| :--- | :---: | :--- |
|
| 285 |
+
| `API_ID` | ✅ | Telegram API ID (from my.telegram.org) |
|
| 286 |
+
| `API_HASH` | ✅ | Telegram API Hash (from my.telegram.org) |
|
| 287 |
+
| `BOT_TOKEN` | ✅ | Your bot token (from @BotFather) |
|
| 288 |
+
| `OWNER_ID` | ✅ | Your numeric Telegram user ID |
|
| 289 |
+
| `DATABASE` | ✅ | **Two** MongoDB URIs, separated by a comma |
|
| 290 |
+
| `PORT` | ✅ | Web server port (keep `8000` unless it’s busy) |
|
| 291 |
+
| `USER_SESSION_STRING` | ⬜ | Optional — only needed for **Global Search** |
|
| 292 |
+
|
| 293 |
+
A completed file looks like this (these are just example values):
|
| 294 |
+
|
| 295 |
+
```env
|
| 296 |
+
API_ID="1234567"
|
| 297 |
+
API_HASH="abc123def456ghi789jkl012mno345pq"
|
| 298 |
+
BOT_TOKEN="1234567890:AAEabcdEFGhijkLMnOPqrsTUVwxyz12345"
|
| 299 |
+
USER_SESSION_STRING=""
|
| 300 |
+
OWNER_ID="987654321"
|
| 301 |
+
DATABASE="mongodb+srv://user:pass@cluster0.xxxx.mongodb.net/tracking,mongodb+srv://user:pass@cluster0.xxxx.mongodb.net/storage1"
|
| 302 |
+
PORT="8000"
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
> 💾 To save in nano: press `Ctrl + O`, then `Enter`, then `Ctrl + X`.
|
| 306 |
|
| 307 |
---
|
| 308 |
|
| 309 |
+
## 🔑 Step 2: How to Get Each Value
|
| 310 |
|
| 311 |
+
Take it one line at a time — each value comes from a quick, free step.
|
| 312 |
|
| 313 |
+
### 🆔 API_ID & API_HASH
|
| 314 |
+
1. Go to **https://my.telegram.org** and log in with your phone number.
|
| 315 |
+
2. Open **API development tools**.
|
| 316 |
+
3. Create an app (any title works, e.g. `stremio`).
|
| 317 |
+
4. Copy **App api_id** → `API_ID` and **App api_hash** → `API_HASH`.
|
| 318 |
|
| 319 |
+
### 🤖 BOT_TOKEN
|
| 320 |
+
1. Open **@BotFather** in Telegram.
|
| 321 |
+
2. Send `/newbot` and follow the prompts (choose a name and a username).
|
| 322 |
+
3. Copy the token it gives you → `BOT_TOKEN`.
|
| 323 |
+
4. ⭐ Add this bot as an **admin** in every channel you’ll use for media.
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
+
### 👤 OWNER_ID
|
| 326 |
+
1. Open **@userinfobot** in Telegram (or send `/id` to **@MissRose_bot**).
|
| 327 |
+
2. It replies with your numeric ID → `OWNER_ID`.
|
| 328 |
|
| 329 |
+
### 🗄️ DATABASE (two MongoDB URIs)
|
| 330 |
+
You need **two** free MongoDB databases — the first stores tracking/metadata, the second stores your media references.
|
| 331 |
|
| 332 |
+
1. Create a free account at **https://www.mongodb.com/atlas** (the forever-free **M0** tier is enough to start).
|
| 333 |
+
2. Create a cluster → in **Database Access**, add a database user and password.
|
| 334 |
+
3. In **Network Access**, add `0.0.0.0/0` (allow access from anywhere).
|
| 335 |
+
4. Click **Connect → Drivers** and copy the connection string, e.g.
|
| 336 |
+
`mongodb+srv://user:pass@cluster0.xxxx.mongodb.net/`
|
| 337 |
+
5. Add a database name at the end of each (e.g. `/tracking` and `/storage1`).
|
| 338 |
+
6. Put **both** strings on one line, separated by a comma:
|
| 339 |
+
```
|
| 340 |
+
DATABASE="mongodb+srv://.../tracking,mongodb+srv://.../storage1"
|
| 341 |
+
```
|
| 342 |
|
| 343 |
+
> 💡 You can use the **same cluster** for both — just give them two different database names. Need more space later? Add extra storage databases from the Web Settings page (no restart required).
|
| 344 |
|
| 345 |
+
### 🔢 PORT
|
| 346 |
+
Leave it as `8000` unless that port is already in use. Your reverse proxy / domain will point here.
|
| 347 |
|
| 348 |
+
### 📱 USER_SESSION_STRING (optional)
|
| 349 |
+
Only needed if you want **Global Search**. It’s safe and quick to generate — see **Step 3** below. If you don’t need Global Search, leave it empty.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
---
|
| 352 |
|
| 353 |
+
## 📱 Step 3: Generate Your Telegram Session String
|
| 354 |
|
| 355 |
+
> 😊 **No app installation required — and it’s safe.**
|
| 356 |
+
> A session string is simply a “stay logged in” token for **your own** Telegram account, exactly like signing into Telegram Web. The bot never sees your password, and you can revoke access anytime from **Telegram → Settings → Devices**.
|
|
|
|
|
|
|
| 357 |
|
| 358 |
+
> ⏭️ **Skip this step** entirely if you don’t plan to use Global Search.
|
| 359 |
|
| 360 |
+
### 🌐 Easiest Method: Google Colab (works right in your phone’s browser)
|
| 361 |
|
| 362 |
+
1️⃣ Open **https://colab.new** in your browser.
|
|
|
|
|
|
|
|
|
|
| 363 |
|
| 364 |
+
2️⃣ Sign in with your Google account.
|
| 365 |
|
| 366 |
+
3️⃣ Tap **“+ Code”** to add a new code cell.
|
| 367 |
|
| 368 |
+
4️⃣ Paste the code below and press ▶ **Run**:
|
|
|
|
|
|
|
| 369 |
|
| 370 |
+
```python
|
| 371 |
+
!pip install pyrogram tgcrypto
|
| 372 |
+
|
| 373 |
+
import asyncio
|
| 374 |
+
from pyrogram import Client
|
| 375 |
|
| 376 |
+
api_id = int(input("API ID: "))
|
| 377 |
+
api_hash = input("API HASH: ")
|
| 378 |
|
| 379 |
+
async def main():
|
| 380 |
+
async with Client("temp_session", api_id, api_hash) as app:
|
| 381 |
+
print("\nYour USER_SESSION_STRING is:\n")
|
| 382 |
+
print(await app.export_session_string())
|
| 383 |
+
|
| 384 |
+
await main()
|
| 385 |
+
```
|
| 386 |
|
| 387 |
+
5️⃣ Enter your **API ID** and **API HASH** when prompted.
|
| 388 |
|
| 389 |
+
6️⃣ Enter the **login code** Telegram sends you (and your 2-step password, if you have one).
|
| 390 |
|
| 391 |
+
7️⃣ Your **USER_SESSION_STRING** is printed on screen — copy the whole string into `config.env`.
|
| 392 |
|
| 393 |
+
> 🔒 **Keep it private.** Anyone who has this string can access your account, so never share it or commit it to a public repository. To invalidate it instantly, just remove the session from Telegram’s **Devices** list.
|
|
|
|
|
|
|
|
|
|
| 394 |
|
| 395 |
---
|
| 396 |
|
| 397 |
+
## 🧩 Step 4: Configure Everything Else (Web Settings Page)
|
| 398 |
+
|
| 399 |
+
Once the server is running, open it in your browser:
|
| 400 |
+
|
| 401 |
+
| Setup | Open this URL |
|
| 402 |
+
| :--- | :--- |
|
| 403 |
+
| **VPS with a domain** | `https://your-domain.com` |
|
| 404 |
+
| **Local / direct IP** | `http://<your-vps-ip>:8000` |
|
| 405 |
+
|
| 406 |
+
You’ll land on the **login page** (`/login`). Sign in with the default credentials:
|
| 407 |
+
|
| 408 |
+
```
|
| 409 |
+
Username: admin
|
| 410 |
+
Password: admin
|
| 411 |
+
```
|
| 412 |
|
| 413 |
+
Then go to **Settings** (`/admin/settings`).
|
| 414 |
|
| 415 |
+
> 🚨 **Do this first:** change the admin password in the **Admin Authentication** card, then click **Save Settings**.
|
|
|
|
|
|
|
|
|
|
| 416 |
|
| 417 |
+
Everything below is stored in the database and applied **instantly — no restart needed** (the only exception is `USER_SESSION_STRING`, which lives in `config.env`).
|
| 418 |
|
| 419 |
+
### ⚙️ General
|
| 420 |
+
| Option | What it does |
|
| 421 |
+
| :--- | :--- |
|
| 422 |
+
| **Replace Mode** | When a new file has the same quality (`720p`, `1080p`…) as an existing one, it replaces the old entry. Recommended **ON**. |
|
| 423 |
+
| **Hide Catalog** | Hides the public Stremio catalog (direct streams still work). |
|
| 424 |
+
|
| 425 |
+
### 🛡️ Admin Authentication
|
| 426 |
+
| Field | What to enter |
|
| 427 |
+
| :--- | :--- |
|
| 428 |
+
| **Admin Username / Password** | Your Web Panel login. Leave the password blank to keep the current one. **Change the defaults right away.** |
|
| 429 |
+
| **AUTH_CHANNELS** | The channel(s) the bot indexes and streams from. Add each one by `@username` or `-100…` ID. Make sure your bot is an **admin** in each channel. |
|
| 430 |
+
|
| 431 |
+
### 🎬 Media & Content
|
| 432 |
+
| Field | What to enter |
|
| 433 |
+
| :--- | :--- |
|
| 434 |
+
| **TMDB API Key** | A free TMDB **v3** key from themoviedb.org → Settings → API. Powers automatic metadata matching. |
|
| 435 |
+
| **Base URL** | Your public address, e.g. `https://your-domain.com`. **Important:** Stremio uses this to reach your streams. |
|
| 436 |
+
| **Upstream Repo / Branch** | Optional — used by `/restart` to auto-update (e.g. repo `weebzone/Telegram-Stremio`, branch `master`). |
|
| 437 |
+
|
| 438 |
+
### 💳 Subscription (optional)
|
| 439 |
+
Turn this on to monetise access. Set the **Subscription Group ID**, **Subscription URL**, **Payment Instructions** (your UPI / bank / PayPal text), an optional **Payment QR image URL**, and the **Approver IDs** (who can approve requests). The full flow is described in [Subscription Management](#-subscription-management).
|
| 440 |
+
|
| 441 |
+
### 🌐 Global Search (optional)
|
| 442 |
+
Requires `USER_SESSION_STRING` in `config.env` plus one app restart to unlock. Then enable the toggle and add the **channel IDs** to search. Results that aren’t in your local catalog are tagged **🌐 GLOBAL** in Stremio.
|
| 443 |
+
|
| 444 |
+
### 🌐 Proxy (optional)
|
| 445 |
+
Set an **HTTP Proxy URL** for outbound metadata/API requests, and optionally **show both** proxied and direct stream links.
|
| 446 |
+
|
| 447 |
+
### 🗄️ Extra Storage Databases
|
| 448 |
+
Your first two databases (from `config.env`) are **locked** as *Tracking* and *Storage 1*. Add more MongoDB URIs here to expand storage capacity — 🟢 means connected. Remove entries only from the **end** of the list, since existing media reference databases by position.
|
| 449 |
+
|
| 450 |
+
### 📨 Multi-Token Clients
|
| 451 |
+
Add extra **bot tokens** for faster parallel streaming under heavy load. Create more bots with @BotFather, add them as **admins** in all your AUTH channels, then paste their tokens here. Changes apply immediately.
|
| 452 |
+
|
| 453 |
+
> ✅ Click **Save Settings** when you’re done. That’s it — you’re live!
|
| 454 |
|
| 455 |
---
|
| 456 |
|
|
|
|
| 739 |
➡️ `https://your-domain.com`
|
| 740 |
|
| 741 |
|
| 742 |
+
# 📺 Setting Up Your App (Nuvio Recommended)
|
| 743 |
+
|
| 744 |
+
Your media server works as a standard **Stremio-style addon**, so it plays in any compatible client. For the **best compatibility and smoothest experience across devices, we recommend the [Nuvio](https://play.google.com/store/apps/details?id=com.nuvio.app) app** — a free, open-source media hub for **Android, Android TV, Fire TV, iOS, Windows, and TV** that supports Stremio addon manifest URLs natively. *(Content was rephrased for compliance with licensing restrictions.)*
|
| 745 |
|
| 746 |
+
> 💡 Already using **Stremio**? It works too — just install the same addon URL below. Nuvio simply tends to handle these Telegram streams more reliably across more devices.
|
| 747 |
|
| 748 |
+
## 📥 Step 1: Install Nuvio
|
| 749 |
|
| 750 |
+
Download Nuvio from an official source:
|
|
|
|
| 751 |
|
| 752 |
+
| Platform | Source |
|
| 753 |
+
| :--- | :--- |
|
| 754 |
+
| **Android / Android TV / Fire TV** | [Google Play](https://play.google.com/store/apps/details?id=com.nuvio.app) |
|
| 755 |
+
| **All platforms / latest builds** | [GitHub — tapframe/NuvioStreaming](https://github.com/tapframe/NuvioStreaming) |
|
| 756 |
|
| 757 |
+
> 🔗 *(Optional)* Connect **Trakt** in the app to sync your watch history and progress across devices.
|
| 758 |
|
| 759 |
+
## 🌐 Step 2: Add the Addon
|
| 760 |
|
| 761 |
+
1. Open **Nuvio** and go to the **Addons** section.
|
| 762 |
+
2. Paste your addon **manifest URL** and install it:
|
|
|
|
| 763 |
|
| 764 |
| Deployment Method | Addon URL |
|
| 765 |
| :--- | :--- |
|
| 766 |
| **Heroku** | `https://<your-heroku-app>.herokuapp.com/stremio/manifest.json` |
|
| 767 |
| **Custom Domain** | `https://<your-domain>/stremio/manifest.json` |
|
| 768 |
|
| 769 |
+
3. Done! 🎉 Your Telegram library now appears in the catalog and streams directly.
|
| 770 |
|
| 771 |
+
> 🔑 If you run in **subscription mode**, each user installs their own **personal** addon URL (`/stremio/{token}/manifest.json`) that the bot gives them.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 772 |
|
| 773 |
|
| 774 |
+
## 🏅 Contributors
|
| 775 |
|
| 776 |
|<img width="80" src="https://avatars.githubusercontent.com/u/113664541">|<img width="80" src="https://avatars.githubusercontent.com/u/13152917">|<img width="80" src="https://avatars.githubusercontent.com/u/14957082">|<img width="80" src="https://raw.githubusercontent.com/vflixa1prime/Readme/main/VFlixPRime.png">|
|
| 777 |
|:---:|:---:|:---:|:---:|
|
pyproject.toml
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
[project]
|
| 2 |
name = "Telegram-Stremio"
|
| 3 |
-
version = "3.
|
| 4 |
description = "A powerful, self-hosted Telegram Stremio Media Server built with FastAPI, MongoDB, and PyroFork seamlessly integrated with Stremio for automated media streaming and discovery."
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.11"
|
| 7 |
dependencies = [
|
| 8 |
"aiofiles>=24.1.0",
|
| 9 |
"fastapi>=0.115.12",
|
|
|
|
| 10 |
"httptools>=0.8.0",
|
| 11 |
"httpx>=0.28.1",
|
| 12 |
"itsdangerous>=2.2.0",
|
|
@@ -17,6 +18,7 @@ dependencies = [
|
|
| 17 |
"python-dotenv>=1.1.0",
|
| 18 |
"python-multipart>=0.0.20",
|
| 19 |
"pytz>=2025.2",
|
|
|
|
| 20 |
"requests>=2.32.3",
|
| 21 |
"tgcrypto>=1.2.5",
|
| 22 |
"themoviedb>=1.0.2",
|
|
|
|
| 1 |
[project]
|
| 2 |
name = "Telegram-Stremio"
|
| 3 |
+
version = "3.4.0"
|
| 4 |
description = "A powerful, self-hosted Telegram Stremio Media Server built with FastAPI, MongoDB, and PyroFork seamlessly integrated with Stremio for automated media streaming and discovery."
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.11"
|
| 7 |
dependencies = [
|
| 8 |
"aiofiles>=24.1.0",
|
| 9 |
"fastapi>=0.115.12",
|
| 10 |
+
"guessit>=3.8.0",
|
| 11 |
"httptools>=0.8.0",
|
| 12 |
"httpx>=0.28.1",
|
| 13 |
"itsdangerous>=2.2.0",
|
|
|
|
| 18 |
"python-dotenv>=1.1.0",
|
| 19 |
"python-multipart>=0.0.20",
|
| 20 |
"pytz>=2025.2",
|
| 21 |
+
"rapidfuzz>=3.14.5",
|
| 22 |
"requests>=2.32.3",
|
| 23 |
"tgcrypto>=1.2.5",
|
| 24 |
"themoviedb>=1.0.2",
|
requirements.txt
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
aiofiles
|
| 2 |
fastapi
|
|
|
|
| 3 |
httpx
|
| 4 |
motor
|
| 5 |
parse-torrent-title
|
| 6 |
pyrofork
|
|
|
|
| 7 |
python-dotenv
|
| 8 |
pytz
|
| 9 |
tgcrypto
|
|
|
|
| 1 |
aiofiles
|
| 2 |
fastapi
|
| 3 |
+
guessit
|
| 4 |
httpx
|
| 5 |
motor
|
| 6 |
parse-torrent-title
|
| 7 |
pyrofork
|
| 8 |
+
rapidfuzz
|
| 9 |
python-dotenv
|
| 10 |
pytz
|
| 11 |
tgcrypto
|
uv.lock
CHANGED
|
@@ -129,6 +129,15 @@ wheels = [
|
|
| 129 |
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
|
| 130 |
]
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
[[package]]
|
| 133 |
name = "certifi"
|
| 134 |
version = "2025.4.26"
|
|
@@ -338,6 +347,20 @@ wheels = [
|
|
| 338 |
{ url = "https://files.pythonhosted.org/packages/71/3e/b04a0adda73bd52b390d730071c0d577073d3d26740ee1bad25c3ad0f37b/frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", size = 12404, upload-time = "2025-04-17T22:38:51.668Z" },
|
| 339 |
]
|
| 340 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
[[package]]
|
| 342 |
name = "h11"
|
| 343 |
version = "0.16.0"
|
|
@@ -867,6 +890,18 @@ wheels = [
|
|
| 867 |
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
|
| 868 |
]
|
| 869 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 870 |
[[package]]
|
| 871 |
name = "python-dotenv"
|
| 872 |
version = "1.1.0"
|
|
@@ -894,6 +929,94 @@ wheels = [
|
|
| 894 |
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
|
| 895 |
]
|
| 896 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 897 |
[[package]]
|
| 898 |
name = "requests"
|
| 899 |
version = "2.32.3"
|
|
@@ -921,6 +1044,15 @@ wheels = [
|
|
| 921 |
{ url = "https://files.pythonhosted.org/packages/bd/60/50fbb6ffb35f733654466f1a90d162bcbea358adc3b0871339254fbc37b2/requirements_parser-0.13.0-py3-none-any.whl", hash = "sha256:2b3173faecf19ec5501971b7222d38f04cb45bb9d87d0ad629ca71e2e62ded14", size = 14782, upload-time = "2025-05-21T13:42:04.007Z" },
|
| 922 |
]
|
| 923 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
[[package]]
|
| 925 |
name = "sniffio"
|
| 926 |
version = "1.3.1"
|
|
@@ -944,11 +1076,12 @@ wheels = [
|
|
| 944 |
|
| 945 |
[[package]]
|
| 946 |
name = "telegram-stremio"
|
| 947 |
-
version = "3.
|
| 948 |
source = { virtual = "." }
|
| 949 |
dependencies = [
|
| 950 |
{ name = "aiofiles" },
|
| 951 |
{ name = "fastapi" },
|
|
|
|
| 952 |
{ name = "httptools" },
|
| 953 |
{ name = "httpx" },
|
| 954 |
{ name = "itsdangerous" },
|
|
@@ -959,6 +1092,7 @@ dependencies = [
|
|
| 959 |
{ name = "python-dotenv" },
|
| 960 |
{ name = "python-multipart" },
|
| 961 |
{ name = "pytz" },
|
|
|
|
| 962 |
{ name = "requests" },
|
| 963 |
{ name = "tgcrypto" },
|
| 964 |
{ name = "themoviedb" },
|
|
@@ -975,6 +1109,7 @@ dev = [
|
|
| 975 |
requires-dist = [
|
| 976 |
{ name = "aiofiles", specifier = ">=24.1.0" },
|
| 977 |
{ name = "fastapi", specifier = ">=0.115.12" },
|
|
|
|
| 978 |
{ name = "httptools", specifier = ">=0.8.0" },
|
| 979 |
{ name = "httpx", specifier = ">=0.28.1" },
|
| 980 |
{ name = "itsdangerous", specifier = ">=2.2.0" },
|
|
@@ -985,6 +1120,7 @@ requires-dist = [
|
|
| 985 |
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
| 986 |
{ name = "python-multipart", specifier = ">=0.0.20" },
|
| 987 |
{ name = "pytz", specifier = ">=2025.2" },
|
|
|
|
| 988 |
{ name = "requests", specifier = ">=2.32.3" },
|
| 989 |
{ name = "tgcrypto", specifier = ">=1.2.5" },
|
| 990 |
{ name = "themoviedb", specifier = ">=1.0.2" },
|
|
|
|
| 129 |
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
|
| 130 |
]
|
| 131 |
|
| 132 |
+
[[package]]
|
| 133 |
+
name = "babelfish"
|
| 134 |
+
version = "0.6.1"
|
| 135 |
+
source = { registry = "https://pypi.org/simple" }
|
| 136 |
+
sdist = { url = "https://files.pythonhosted.org/packages/c5/8f/17ff889327f8a1c36a28418e686727dabc06c080ed49c95e3e2424a77aa6/babelfish-0.6.1.tar.gz", hash = "sha256:decb67a4660888d48480ab6998309837174158d0f1aa63bebb1c2e11aab97aab", size = 87706, upload-time = "2024-05-09T21:16:24.357Z" }
|
| 137 |
+
wheels = [
|
| 138 |
+
{ url = "https://files.pythonhosted.org/packages/95/a1/bd4f759db13cd8beb9c9f68682aced5d966781b9d7380cf514a306f56762/babelfish-0.6.1-py3-none-any.whl", hash = "sha256:512f1501d4c8f7d38f0921f48660be7542de1a7b24abb6a6a65324a670150293", size = 94231, upload-time = "2024-05-09T21:16:22.633Z" },
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
[[package]]
|
| 142 |
name = "certifi"
|
| 143 |
version = "2025.4.26"
|
|
|
|
| 347 |
{ url = "https://files.pythonhosted.org/packages/71/3e/b04a0adda73bd52b390d730071c0d577073d3d26740ee1bad25c3ad0f37b/frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", size = 12404, upload-time = "2025-04-17T22:38:51.668Z" },
|
| 348 |
]
|
| 349 |
|
| 350 |
+
[[package]]
|
| 351 |
+
name = "guessit"
|
| 352 |
+
version = "3.8.0"
|
| 353 |
+
source = { registry = "https://pypi.org/simple" }
|
| 354 |
+
dependencies = [
|
| 355 |
+
{ name = "babelfish" },
|
| 356 |
+
{ name = "python-dateutil" },
|
| 357 |
+
{ name = "rebulk" },
|
| 358 |
+
]
|
| 359 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d0/07/5a88020bfe2591af2ffc75841200b2c17ff52510779510346af5477e64cd/guessit-3.8.0.tar.gz", hash = "sha256:6619fcbbf9a0510ec8c2c33744c4251cad0507b1d573d05c875de17edc5edbed", size = 271285, upload-time = "2023-12-14T10:24:20.582Z" }
|
| 360 |
+
wheels = [
|
| 361 |
+
{ url = "https://files.pythonhosted.org/packages/b8/9b/924a8f9709c3143d4c9a301a88042cbce37e09789378217284b19d4f8132/guessit-3.8.0-py3-none-any.whl", hash = "sha256:eb5747b1d0fbca926562c1e5894dbc3f6507c35e8c0bd9e38148401cd9579d83", size = 206049, upload-time = "2023-12-14T10:24:17.753Z" },
|
| 362 |
+
]
|
| 363 |
+
|
| 364 |
[[package]]
|
| 365 |
name = "h11"
|
| 366 |
version = "0.16.0"
|
|
|
|
| 890 |
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
|
| 891 |
]
|
| 892 |
|
| 893 |
+
[[package]]
|
| 894 |
+
name = "python-dateutil"
|
| 895 |
+
version = "2.9.0.post0"
|
| 896 |
+
source = { registry = "https://pypi.org/simple" }
|
| 897 |
+
dependencies = [
|
| 898 |
+
{ name = "six" },
|
| 899 |
+
]
|
| 900 |
+
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
| 901 |
+
wheels = [
|
| 902 |
+
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
| 903 |
+
]
|
| 904 |
+
|
| 905 |
[[package]]
|
| 906 |
name = "python-dotenv"
|
| 907 |
version = "1.1.0"
|
|
|
|
| 929 |
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
|
| 930 |
]
|
| 931 |
|
| 932 |
+
[[package]]
|
| 933 |
+
name = "rapidfuzz"
|
| 934 |
+
version = "3.14.5"
|
| 935 |
+
source = { registry = "https://pypi.org/simple" }
|
| 936 |
+
sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" }
|
| 937 |
+
wheels = [
|
| 938 |
+
{ url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" },
|
| 939 |
+
{ url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" },
|
| 940 |
+
{ url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" },
|
| 941 |
+
{ url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" },
|
| 942 |
+
{ url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" },
|
| 943 |
+
{ url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" },
|
| 944 |
+
{ url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" },
|
| 945 |
+
{ url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" },
|
| 946 |
+
{ url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" },
|
| 947 |
+
{ url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" },
|
| 948 |
+
{ url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" },
|
| 949 |
+
{ url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" },
|
| 950 |
+
{ url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" },
|
| 951 |
+
{ url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" },
|
| 952 |
+
{ url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" },
|
| 953 |
+
{ url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" },
|
| 954 |
+
{ url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" },
|
| 955 |
+
{ url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" },
|
| 956 |
+
{ url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" },
|
| 957 |
+
{ url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" },
|
| 958 |
+
{ url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" },
|
| 959 |
+
{ url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" },
|
| 960 |
+
{ url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" },
|
| 961 |
+
{ url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" },
|
| 962 |
+
{ url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" },
|
| 963 |
+
{ url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" },
|
| 964 |
+
{ url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" },
|
| 965 |
+
{ url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" },
|
| 966 |
+
{ url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" },
|
| 967 |
+
{ url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" },
|
| 968 |
+
{ url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" },
|
| 969 |
+
{ url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" },
|
| 970 |
+
{ url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" },
|
| 971 |
+
{ url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" },
|
| 972 |
+
{ url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" },
|
| 973 |
+
{ url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" },
|
| 974 |
+
{ url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" },
|
| 975 |
+
{ url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" },
|
| 976 |
+
{ url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" },
|
| 977 |
+
{ url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" },
|
| 978 |
+
{ url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" },
|
| 979 |
+
{ url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" },
|
| 980 |
+
{ url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" },
|
| 981 |
+
{ url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" },
|
| 982 |
+
{ url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" },
|
| 983 |
+
{ url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" },
|
| 984 |
+
{ url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" },
|
| 985 |
+
{ url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" },
|
| 986 |
+
{ url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" },
|
| 987 |
+
{ url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" },
|
| 988 |
+
{ url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" },
|
| 989 |
+
{ url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" },
|
| 990 |
+
{ url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" },
|
| 991 |
+
{ url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" },
|
| 992 |
+
{ url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" },
|
| 993 |
+
{ url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" },
|
| 994 |
+
{ url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" },
|
| 995 |
+
{ url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" },
|
| 996 |
+
{ url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" },
|
| 997 |
+
{ url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" },
|
| 998 |
+
{ url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" },
|
| 999 |
+
{ url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" },
|
| 1000 |
+
{ url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" },
|
| 1001 |
+
{ url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" },
|
| 1002 |
+
{ url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" },
|
| 1003 |
+
{ url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" },
|
| 1004 |
+
{ url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" },
|
| 1005 |
+
{ url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" },
|
| 1006 |
+
{ url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" },
|
| 1007 |
+
{ url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" },
|
| 1008 |
+
{ url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" },
|
| 1009 |
+
]
|
| 1010 |
+
|
| 1011 |
+
[[package]]
|
| 1012 |
+
name = "rebulk"
|
| 1013 |
+
version = "3.2.0"
|
| 1014 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1015 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f2/06/24c69f8d707c9eefc1108a64e079da56b5f351e3f59ed76e8f04b9f3e296/rebulk-3.2.0.tar.gz", hash = "sha256:0d30bf80fca00fa9c697185ac475daac9bde5f646ce3338c9ff5d5dc1ebdfebc", size = 261685, upload-time = "2023-02-18T09:10:14.378Z" }
|
| 1016 |
+
wheels = [
|
| 1017 |
+
{ url = "https://files.pythonhosted.org/packages/84/4d/df073d593f7e7e4a5a7e19148b2e9b4ae63b4ddcbb863f1e7bb2b6f19c62/rebulk-3.2.0-py3-none-any.whl", hash = "sha256:6bc31ae4b37200623c5827d2f539f9ec3e52b50431322dad8154642a39b0a53e", size = 56298, upload-time = "2023-02-18T09:10:12.435Z" },
|
| 1018 |
+
]
|
| 1019 |
+
|
| 1020 |
[[package]]
|
| 1021 |
name = "requests"
|
| 1022 |
version = "2.32.3"
|
|
|
|
| 1044 |
{ url = "https://files.pythonhosted.org/packages/bd/60/50fbb6ffb35f733654466f1a90d162bcbea358adc3b0871339254fbc37b2/requirements_parser-0.13.0-py3-none-any.whl", hash = "sha256:2b3173faecf19ec5501971b7222d38f04cb45bb9d87d0ad629ca71e2e62ded14", size = 14782, upload-time = "2025-05-21T13:42:04.007Z" },
|
| 1045 |
]
|
| 1046 |
|
| 1047 |
+
[[package]]
|
| 1048 |
+
name = "six"
|
| 1049 |
+
version = "1.17.0"
|
| 1050 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1051 |
+
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
| 1052 |
+
wheels = [
|
| 1053 |
+
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
| 1054 |
+
]
|
| 1055 |
+
|
| 1056 |
[[package]]
|
| 1057 |
name = "sniffio"
|
| 1058 |
version = "1.3.1"
|
|
|
|
| 1076 |
|
| 1077 |
[[package]]
|
| 1078 |
name = "telegram-stremio"
|
| 1079 |
+
version = "3.3.0"
|
| 1080 |
source = { virtual = "." }
|
| 1081 |
dependencies = [
|
| 1082 |
{ name = "aiofiles" },
|
| 1083 |
{ name = "fastapi" },
|
| 1084 |
+
{ name = "guessit" },
|
| 1085 |
{ name = "httptools" },
|
| 1086 |
{ name = "httpx" },
|
| 1087 |
{ name = "itsdangerous" },
|
|
|
|
| 1092 |
{ name = "python-dotenv" },
|
| 1093 |
{ name = "python-multipart" },
|
| 1094 |
{ name = "pytz" },
|
| 1095 |
+
{ name = "rapidfuzz" },
|
| 1096 |
{ name = "requests" },
|
| 1097 |
{ name = "tgcrypto" },
|
| 1098 |
{ name = "themoviedb" },
|
|
|
|
| 1109 |
requires-dist = [
|
| 1110 |
{ name = "aiofiles", specifier = ">=24.1.0" },
|
| 1111 |
{ name = "fastapi", specifier = ">=0.115.12" },
|
| 1112 |
+
{ name = "guessit", specifier = ">=3.8.0" },
|
| 1113 |
{ name = "httptools", specifier = ">=0.8.0" },
|
| 1114 |
{ name = "httpx", specifier = ">=0.28.1" },
|
| 1115 |
{ name = "itsdangerous", specifier = ">=2.2.0" },
|
|
|
|
| 1120 |
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
| 1121 |
{ name = "python-multipart", specifier = ">=0.0.20" },
|
| 1122 |
{ name = "pytz", specifier = ">=2025.2" },
|
| 1123 |
+
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
| 1124 |
{ name = "requests", specifier = ">=2.32.3" },
|
| 1125 |
{ name = "tgcrypto", specifier = ">=1.2.5" },
|
| 1126 |
{ name = "themoviedb", specifier = ">=1.0.2" },
|