Karan Adhikari commited on
Commit
ebc2aee
·
unverified ·
1 Parent(s): 06433ef
Files changed (7) hide show
  1. Backend/__init__.py +1 -1
  2. Backend/helper/imdb.py +85 -131
  3. Backend/helper/metadata.py +431 -698
  4. README.md +179 -175
  5. pyproject.toml +3 -1
  6. requirements.txt +2 -0
  7. 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.3.0"
 
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" # emoticons
15
- "\U0001F300-\U0001F5FF" # symbols & pictographs
16
- "\U0001F680-\U0001F6FF" # transport & map
17
- "\U0001F700-\U0001FAFF" # extended symbols
18
- "\U00002702-\U000027B0" # dingbats
19
- "\U000024C2-\U0001F251" # enclosed chars
20
- "\u2600-\u26FF" # misc symbols
21
- "\u2700-\u27BF" # dingbats block
22
- "\uFE00-\uFE0F" # variation selectors
23
- "\U0001F1E0-\U0001F1FF" # flags
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
- q = re.sub(r"\s+", " ", q).strip()
49
- return q
50
-
51
 
52
  def extract_first_year(year_string) -> int:
53
  if not year_string:
54
  return 0
55
- year_str = str(year_string)
56
- year_match = re.search(r'(\d{4})', year_str)
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
- url = f"{BASE_URL}/catalog/{cinemeta_type}/imdb/search={normalized}.json"
84
- try:
85
- resp = await client.get(url)
86
- if resp.status_code != 200:
87
- return []
88
- data = resp.json()
89
- results: List[Dict[str, Any]] = []
90
- if data and "metas" in data and data["metas"]:
91
- for meta in data["metas"][:limit]:
92
- imdb_id = meta.get("imdb_id") or meta.get("id", "")
93
- if not imdb_id:
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
- "plot": meta.get("description", ""),
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
- "background": meta.get("background", ""),
141
- "logo": meta.get("logo", ""),
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
- except Exception:
 
 
 
149
  return None
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
  async def get_season(imdb_id: str, season_id: int, episode_id: int) -> Optional[Dict[str, Any]]:
153
- client = await _get_client()
154
- try:
155
- url = f"{BASE_URL}/meta/series/{imdb_id}.json"
156
- resp = await client.get(url)
157
- if resp.status_code != 200:
158
- return None
159
- data = resp.json()
160
- if 'meta' in data and 'videos' in data['meta']:
161
- for video in data['meta']['videos']:
162
- if (str(video.get('season', '')) == str(season_id) and
163
- str(video.get('episode', '')) == str(episode_id)):
164
- return {
165
- 'title': video.get('title', f'Episode {episode_id}'),
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
- from re import compile, IGNORECASE
6
- from difflib import SequenceMatcher
7
  from typing import Optional, List
8
 
9
- from Backend.helper.imdb import get_detail, get_season, search_title, search_title_multi
10
- from themoviedb import aioTMDb
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
- DELAY = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if not path:
54
- return ""
55
- return f"https://image.tmdb.org/t/p/{size}{path}"
56
-
57
 
58
  def get_tmdb_logo(images) -> str:
59
- if not images:
60
- return ""
61
- logos = getattr(images, "logos", None)
62
  if not logos:
63
  return ""
64
  for logo in logos:
65
- iso_lang = getattr(logo, "iso_639_1", None)
66
- file_path = getattr(logo, "file_path", None)
67
- if iso_lang == "en" and file_path:
68
- return format_tmdb_image(file_path, "w300")
69
  for logo in logos:
70
- file_path = getattr(logo, "file_path", None)
71
- if file_path:
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
- # ----------------- Fuzzy-matching helpers -----------------
 
 
 
 
 
 
 
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
- # Remove leading articles
129
- t = re.sub(r'^\b(the|a|an)\b\s+', '', t)
130
- # Replace punctuation with spaces
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 SequenceMatcher(None, n1, n2).ratio()
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'(\d{4})', str(year_val))
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) # exact year: strong boost
168
  elif diff == 1:
169
- score = min(1.0, score + 0.07) # off by one: small boost
170
- elif diff <= 2:
171
- pass # neutral
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
- # 3. Punctuation-stripped version (handles colons, hyphens, apostrophes)
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
- # 4. Without leading article (The / A / An)
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
- result: List[str] = []
210
  for v in variants:
211
- k = v.lower()
212
- if k not in seen and v:
213
- seen.add(k)
214
- result.append(v)
215
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
 
 
 
 
 
 
 
 
 
 
217
 
218
- # ----------------- Cached API wrappers -----------------
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: float = 0.0
236
- best_result_title: str = ""
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
- r_title = r.get("title", "")
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
- best_id = r.get("id")
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"| best candidate: '{best_result_title}' [{best_id}] score={best_score:.2f} "
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
- async with API_SEMAPHORE:
301
- if type_ == "movie":
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], type_: str):
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
- best_item = None
343
- best_score = 0.0
344
-
345
  for item in results:
346
- if type_ == "movie":
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
- if best_score >= 0.92:
362
- break
363
-
364
- if best_score >= _TMDB_THRESHOLD and best_item is not None:
365
  return best_item
366
 
367
- return None
 
 
 
 
 
 
 
 
 
 
368
 
 
369
 
370
- async def _tmdb_movie_details(movie_id):
371
- if movie_id in TMDB_DETAILS_CACHE:
372
- return TMDB_DETAILS_CACHE[movie_id]
 
 
 
 
373
  try:
 
374
  async with API_SEMAPHORE:
375
- details = await get_tmdb_client().movie(movie_id).details(
376
- append_to_response="external_ids,credits"
377
- )
378
- images = await get_tmdb_client().movie(movie_id).images()
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 movie details fetch failed for id={movie_id}: {e}")
385
- TMDB_DETAILS_CACHE[movie_id] = None
386
- return None
387
-
388
-
389
- async def _tmdb_tv_details(tv_id):
390
- if tv_id in TMDB_DETAILS_CACHE:
391
- return TMDB_DETAILS_CACHE[tv_id]
392
  try:
 
393
  async with API_SEMAPHORE:
394
- details = await get_tmdb_client().tv(tv_id).details(
395
- append_to_response="external_ids,credits"
396
- )
397
- images = await get_tmdb_client().tv(tv_id).images()
398
- details.images = images
399
- TMDB_DETAILS_CACHE[tv_id] = details
400
  return details
401
  except Exception as e:
402
- LOGGER.warning(f"TMDb tv details fetch failed for id={tv_id}: {e}")
403
- TMDB_DETAILS_CACHE[tv_id] = None
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 = PTN.parse(filename)
428
  except Exception as e:
429
- LOGGER.error(f"PTN parsing failed for {filename}: {e}\n{traceback.format_exc()}")
430
  return None
431
 
432
-
433
- # Skip split files that are not supported
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
- # Skip combined/invalid files
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("resolution")
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
- # --- Resolve override / default ID ---
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(data)
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
- # TV metadata
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 = None
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
- if imdb_id in IMDB_CACHE and isinstance(IMDB_CACHE.get(imdb_id), dict):
550
- imdb_tv = IMDB_CACHE[imdb_id]
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}] → {e}")
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
- matched_title = imdb_tv.get("title", "")
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
- # 5. Fallback: TMDb
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
- # Try with year first, then without
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 _tmdb_tv_details(tmdb_id)
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
- credits = getattr(tv, "credits", None) or {}
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 = None
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
- if imdb_id in IMDB_CACHE and isinstance(IMDB_CACHE.get(imdb_id), dict):
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}] → {e}")
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
- matched_title = imdb_details.get("title", "")
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
- # 5. Fallback: TMDb
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 _tmdb_movie_details(tmdb_id)
774
  if not movie:
775
  LOGGER.info(f"TMDb movie details failed for id={tmdb_id} ('{title}')")
776
  return None
 
777
 
778
- credits = getattr(movie, "credits", None) or {}
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
- return {
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
- "tmdb_id": imdb.get("moviedb_id") or (imdb_id.replace("tt", "") if imdb_id else None),
 
 
814
  "imdb_id": imdb_id,
815
- "title": imdb.get("title", title),
816
- "year": imdb.get("releaseDetailed", {}).get("year", 0),
817
- "rate": imdb.get("rating", {}).get("star", 0),
818
- "description": imdb.get("plot", ""),
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
- # Candidate-search helpers (used by the /set command UI)
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
- imdb_result = await search_title(query=query, type="movie")
845
- if imdb_result and imdb_result.get("id"):
846
- key = ("imdb", imdb_result["id"])
847
- if key not in seen:
848
- seen.add(key)
849
- results.append({
850
- "source": "imdb",
851
- "title": imdb_result.get("title", ""),
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 movie candidate search failed for '{query}': {e}")
861
 
862
  try:
863
- async with API_SEMAPHORE:
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(key)
887
-
888
- release_date = getattr(item, "release_date", None)
889
- year_value = getattr(release_date, "year", None) if release_date else None
890
-
891
- results.append({
892
- "source": "tmdb",
893
- "title": getattr(item, "title", "") or "",
894
- "year": year_value or "",
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 movie candidate search failed for '{query}': {e}")
903
 
904
  return results[:limit]
905
 
906
 
907
- async def search_tv_candidates(query: str, limit: int = 8) -> list[dict]:
908
- query = (query or "").strip()
909
- if not query:
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
- # Manual /set command helpers
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
- data = await fetch_movie_metadata(
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": "movie",
1008
  }
1009
 
1010
 
1011
- async def fetch_selected_tv_metadata(selected_id: str) -> dict | None:
 
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
- if selected_id.startswith("tt"):
1022
- imdb_id = selected_id
1023
- elif selected_id.isdigit():
1024
- tmdb_id = int(selected_id)
1025
- use_tmdb = True
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 _tmdb_tv_details(tmdb_id)
1047
  if not tv:
1048
  return None
1049
-
1050
- credits = getattr(tv, "credits", None) or {}
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(tv.first_air_date, "year", 0) if getattr(tv, "first_air_date", None) else 0,
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": 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
- * [🚀 Quick Setup](#-quick-setup)
50
- * [🧩 Startup Configuration](#-startup-configuration)
51
- * [📚 Media & Catalog Settings](#-media--catalog-settings)
52
- * [🌐 Proxy Settings](#-proxy-settings)
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 Stremio](#-setting-up-stremio)
67
 
68
- * [🌐 Add the Addon](#-step-3-add-the-addon)
69
- * [⚙️ Optional: Remove Cinemeta](#️-optional-remove-cinemeta)
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://m.imdb.com/title/tt665723
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
- > 💡 You only need to edit `config.env` for startup credentials, database connection, and optional Userbot configuration. Everything else can be managed from the Web UI.
 
 
 
 
 
 
 
 
267
 
268
  ---
269
 
270
- ## 🧩 Startup Configuration
 
 
 
 
 
 
 
271
 
272
- | Variable | Description |
273
- | :------------------------ | :---------------------------------------------------------------------------------------------------------- |
274
- | **`API_ID`** | Telegram API ID obtained from `my.telegram.org`. Required for all Telegram client connections. |
275
- | **`API_HASH`** | Telegram API Hash obtained from `my.telegram.org`. Required for Telegram authentication. |
276
- | **`BOT_TOKEN`** | Main bot token generated via `@BotFather`. Used for streaming, indexing, and user interactions. |
277
- | **`OWNER_ID`** | Your Telegram User ID. Grants full administrative access to the system. |
278
- | **`DATABASE`** | MongoDB connection URI(s). Used to store metadata, settings, catalogs, subscriptions, and application data. |
279
- | **`PORT`** | Port on which the FastAPI server runs. Default: `8000`. |
280
- | **`USER_SESSION_STRING`** | Optional Userbot session string. Required only for Global Search functionality. |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
  ---
283
 
284
- # ⚙️ Web Panel Configuration
285
 
286
- The following settings are automatically loaded from the database and can be managed through the Web Admin Panel.
287
 
288
- ## 📚 Media & Catalog Settings
 
 
 
 
289
 
290
- | Variable | Description |
291
- | :--------------------------- | :------------------------------------------------------------------------------------------------------------- |
292
- | **`REPLACE_MODE`** | When enabled, newly uploaded files replace existing files with the same quality label (`720p`, `1080p`, etc.). |
293
- | **`HIDE_CATALOG`** | Hides the default Telegram Stremio catalog and shows streams only through Cinemata integration. |
294
- | **`AUTH_CHANNEL`** | Telegram channel IDs used for media indexing and streaming. Multiple channels are supported. |
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
- ## 🌐 Proxy Settings
 
302
 
303
- | Variable | Description |
304
- | :---------------------------------- | :---------------------------------------------------------------------- |
305
- | **`HTTP_PROXY_URL`** | Optional HTTP proxy used for metadata requests and external API access. |
306
- | **`SHOW_PROXY_AND_NON_PROXY_BOTH`** | Shows both proxied and direct stream links simultaneously. |
 
 
 
 
 
 
307
 
308
- ---
309
 
310
- ## 💳 Subscription System
 
311
 
312
- | Variable | Description |
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
- ## 🧠 Admin Panel
322
 
323
- | Variable | Description |
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
- ## 🔄 Update Settings
331
 
332
- | Variable | Description |
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
- ## 🗄️ Multi-Database Support
340
 
341
- | Variable | Description |
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
- ## 🔑 Multi-Token System
 
348
 
349
- | Variable | Description |
350
- | :----------------- | :----------------------------------------------------------------------------------------- |
351
- | **`MULTI_TOKENS`** | Additional bot tokens used to distribute download traffic and reduce Telegram rate limits. |
 
 
 
 
352
 
353
- ### About Multi-Token Support
354
 
355
- If your addon handles a large number of concurrent streams or downloads, Telegram may rate-limit a single bot.
356
 
357
- To avoid this:
358
 
359
- 1. Create multiple bots using `@BotFather`.
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
- ## 🔍 Global Search Requirements
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
- Global Search requires a valid Userbot session.
369
 
370
- * Configure `USER_SESSION_STRING` in `config.env`.
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
- > ⚠️ If `USER_SESSION_STRING` is not configured, Global Search cannot be enabled.
376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
 
378
  ---
379
 
@@ -662,112 +739,39 @@ sudo apt install caddy
662
  ➡️ `https://your-domain.com`
663
 
664
 
665
- # 📺 Setting up Stremio
 
 
666
 
667
- Follow these steps to connect your deployed addon to the **Stremio** app.
668
 
669
- ### 📥 Step 1: Download Stremio
670
 
671
- Download Stremio for your device:
672
- 👉 [https://www.stremio.com/downloads](https://www.stremio.com/downloads)
673
 
674
- ### 👤 Step 2: Sign In
 
 
 
675
 
676
- - Create or log in to your **Stremio account**.
677
 
678
- ### 🌐 Step 3: Add the Addon
679
 
680
- 1. Open the **Stremio App**.
681
- 2. Go to the **Addon Section** (usually represented by a puzzle piece icon 🧩).
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
- ## ⚙️ Optional: Remove Cinemeta
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
- ## 🏅 **Contributor**
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.3.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
  "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.1.0"
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" },