Commit 08800c5

benny-dou <60535774+benny-dou@users.noreply.github.com>
2025-09-08 07:43:50
feat(tmdb): add `/tmdb` to search TMDB database
1 parent 77efbb8
src/messages/preprocess.py
@@ -52,7 +52,6 @@ async def preprocess_media(media: list[dict]) -> list[dict]:
     """
     num_before = len(media)
     logger.trace(f"{num_before} media info before preprocess: {media}")
-
     # Step-1: Photos
     done_photos = []
     for data in media:
@@ -61,7 +60,7 @@ async def preprocess_media(media: list[dict]) -> list[dict]:
             continue
         if photo_path := data.get("photo"):
             valid_photos = [validate_img(photo) for photo in split_long_img(photo_path) if validate_img(photo)]
-            done_photos.extend({"photo": valid_photo} for valid_photo in valid_photos)
+            done_photos.extend({"photo": valid_photo, "has_spoiler": data.get("has_spoiler", False)} for valid_photo in valid_photos)
 
     # Step-2: Videos
     done_videos = []
@@ -84,7 +83,16 @@ async def preprocess_media(media: list[dict]) -> list[dict]:
             for vpath, tpath in zip(valid_videos, thumbs, strict=True):
                 video_info = await parse_media_info(vpath)
                 thumb = valid_thumb if (valid_thumb := validate_img(tpath)) else None
-                done_videos.append({"video": vpath.as_posix(), "width": video_info["width"], "height": video_info["height"], "duration": video_info["duration"], "thumb": thumb})
+                done_videos.append(
+                    {
+                        "video": vpath.as_posix(),
+                        "width": video_info["width"],
+                        "height": video_info["height"],
+                        "duration": video_info["duration"],
+                        "thumb": thumb,
+                        "has_spoiler": data.get("has_spoiler", False),
+                    }
+                )
     # Step-3: Audios
     done_audios = []
     for data in done_videos:
src/messages/sender.py
@@ -171,8 +171,8 @@ async def send_single_media(
     caption = warp_comments(caption)
     message = None
     try:
-        if photo := media.get("photo"):
-            message = await client.send_photo(chat_id=target_chat, photo=photo, caption=caption, show_caption_above_media=caption_above, reply_parameters=reply_parameters)
+        if media.get("photo"):
+            message = await client.send_photo(chat_id=target_chat, **media, caption=caption, show_caption_above_media=caption_above, reply_parameters=reply_parameters)
         elif video := media.get("video"):
             message = await client.send_video(
                 chat_id=target_chat,
src/messages/utils.py
@@ -90,6 +90,15 @@ def equal_prefix(text: str, prefix: str | list[str], ignore_prefix: str | list[s
     return False
 
 
+def remove_prefix(s: str, prefix: str) -> str:
+    """Remove the prefix from the string."""
+    if not prefix:
+        return s
+    if s.lower().startswith(prefix.lower()):
+        return s[len(prefix) :].lstrip()
+    return s
+
+
 def get_reply_to(msg_id: int, reply_msg_id: int | str) -> ReplyParameters:
     if str(reply_msg_id) == "0":
         reply_to = msg_id
@@ -141,20 +150,9 @@ async def smart_split(text: str, chars_per_string: int = TEXT_LENGTH, mode: Pars
 
     def next_sentence(strings: str) -> str:
         # ruff: noqa: RUF001
-        if "\n" in strings:
-            return strings.split("\n")[0] + "\n"
-        if " " in strings:
-            return strings.split(" ")[0] + " "
-        if ". " in strings:
-            return strings.split(". ")[0] + ". "
-        if "。" in strings:
-            return strings.split("。")[0] + "。"
-        if ";" in strings:
-            return strings.split(";")[0] + ";"
-        if "!" in strings:
-            return strings.split("!")[0] + "!"
-        if "?" in strings:
-            return strings.split("?")[0] + "?"
+        sentence_enders = r"[。!? \.\)\n\t)]"
+        if matched := re.search(sentence_enders, strings):
+            return strings[: matched.end()]
         return strings
 
     # for some reason, we may need to prepend `BLOCKQUOTE_EXPANDABLE_DELIM` or append `BLOCKQUOTE_EXPANDABLE_END_DELIM`
src/others/tmdb.py
@@ -0,0 +1,313 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+from collections import defaultdict
+from typing import Literal
+
+from glom import Coalesce, glom
+from pyrogram.client import Client
+from pyrogram.types import Message
+
+from config import CAPTION_LENGTH, PREFIX, PROXY, TEXT_LENGTH, TOKEN
+from messages.parser import parse_msg
+from messages.sender import send2tg
+from messages.utils import blockquote, count_without_entities, equal_prefix, remove_prefix, set_reaction, smart_split, startswith_prefix
+from networking import download_file, download_first_success_urls, download_media, hx_req
+from publish import publish_telegraph
+from utils import seconds_to_hms
+
+HELP = f"""
+🎬**查询影视信息**
+使用说明:
+1. `{PREFIX.TMDB}` + 关键词: 查询影视作品简介及ID
+2. `{PREFIX.TMDB}` + @演员名: 查询演员简介及ID
+3. `{PREFIX.TMDB}` + ID: 根据ID查询详细信息
+ID有三种前缀:
+ - M: 电影
+ - T: 电视剧
+ - P: 演员
+
+⚠️注意: `@演员名` 和 `关键词` 不可组合使用
+
+示例:
+ - `{PREFIX.TMDB} 泰坦尼克`: 查找“泰坦尼克”相关作品
+ - `{PREFIX.TMDB} @莱昂纳多`: 查找演员“莱昂纳多”
+ - `{PREFIX.TMDB} M597`: 查询ID为597的电影详情
+ - `{PREFIX.TMDB} P6193`: 查询ID为6193的演员详情
+"""
+HEADERS = {"accept": "application/json", "Authorization": f"Bearer {TOKEN.TMDB}"}
+
+
+async def search_tmdb(client: Client, message: Message, *, include_adult: bool = True, **kwargs) -> None:
+    """TMDB command handler."""
+    if not startswith_prefix(message.content, prefix=PREFIX.TMDB):
+        return
+    # send docs if without reply
+    if equal_prefix(message.text, prefix=PREFIX.TMDB) and not message.reply_to_message:
+        await send2tg(client, message, texts=HELP, **kwargs)
+        return
+    info = parse_msg(message, silent=True)
+    # reply a message with /audio or /voice
+    if message.reply_to_message:
+        message = message.reply_to_message
+        info = parse_msg(message, use_cache=False, silent=True)  # parse again
+    await set_reaction(client, message, reaction="👌")
+
+    query = remove_prefix(info["text"], prefix=PREFIX.TMDB)
+    if query.startswith(("M", "T", "P")) and query[1:].isdigit():  # check if the query is a TMDB ID
+        resp = await get_details(query)
+    elif query.startswith("@"):
+        resp = await search_people(query[1:], include_adult=include_adult)
+    else:
+        resp = await search_keyword(query, include_adult=include_adult)
+    await send2tg(client, message, **resp, **kwargs)
+    await set_reaction(client, message, reaction="")
+
+
+async def search_keyword(query: str, tmdb_lang: Literal["en-US", "zh-CN"] = "zh-CN", *, include_adult: bool = True) -> dict:
+    """Search movie & TV by keyword.
+
+    query: 泰坦
+
+    Returns: {"texts": str}
+    """
+    params = {"query": query, "include_adult": str(include_adult).lower(), "language": tmdb_lang, "page": 1}
+    url = "https://api.themoviedb.org/3/search/multi"
+    resp = await hx_req(url, headers=HEADERS, params=params, proxy=PROXY.TMDB, check_kv={"page": 1}, check_keys=["results"])
+    if resp.get("hx_error"):
+        return {"texts": resp["hx_error"]}
+    results = [x for x in resp["results"] if x.get("media_type") in ["movie", "tv"]]  # only movie & TV
+    final_msg = ""
+    for item in results:
+        this_msg = ""
+        type_initial = item["media_type"][0].upper()  # M: movie, T: TV
+        original_title = glom(item, Coalesce("original_title", "original_name"), default="")
+        if title := glom(item, Coalesce("title", "original_title", "name", "original_name"), default=""):
+            this_msg += f"`{type_initial}{item['id']}`:[《{title}》](https://www.themoviedb.org/{item['media_type']}/{item['id']})"
+
+        if date := glom(item, Coalesce("release_date", "first_air_date"), default=""):
+            this_msg += f"({date[:4]})"
+
+        if overview := item.get("overview"):
+            if original_title and original_title != title:  # title: 中文名, original_title: 英文名
+                overview = f"《{original_title}》: {overview}"
+            this_msg += f"\n{blockquote(overview)}\n"
+        if await count_without_entities(final_msg + this_msg) > TEXT_LENGTH:
+            break
+        final_msg += f"\n{this_msg.strip()}"
+    if not final_msg:
+        return {"texts": "❌未找到相关作品"}
+
+    return {"texts": final_msg}
+
+
+async def search_people(query: str, tmdb_lang: Literal["en-US", "zh-CN"] = "zh-CN", *, include_adult: bool = True) -> dict:
+    """Search people by keyword.
+
+    query: 莱昂纳多
+
+    Returns: {"texts": str}
+    """
+    params = {"query": query, "include_adult": str(include_adult).lower(), "language": tmdb_lang, "page": 1}
+    api = "https://api.themoviedb.org/3/search/person"
+    resp = await hx_req(api, headers=HEADERS, params=params, proxy=PROXY.TMDB, check_kv={"page": 1}, check_keys=["results"])
+    if resp.get("hx_error"):
+        return {"texts": resp["hx_error"]}
+    final_msg = ""
+    for item in resp["results"]:
+        this_msg = ""
+        name = glom(item, Coalesce("name", "original_name"), default="")
+        url = f"https://www.themoviedb.org/person/{item['id']}"
+        this_msg += f"\n`P{item['id']}`{gender_emoji(item.get('gender'))}: [{name}]({url})"
+        if item.get("original_name") and item["original_name"] != name:
+            this_msg += f"({item['original_name']})"
+        if item.get("adult"):
+            this_msg += "🔞"
+        if item.get("known_for"):
+            this_msg += f"\n代表作: {', '.join([glom(x, Coalesce('title', 'name'), default='') for x in item['known_for']])}\n"
+        if await count_without_entities(final_msg + this_msg) > TEXT_LENGTH:
+            break
+        final_msg += f"\n\n{this_msg.strip()}"
+    if not final_msg:
+        return {"texts": "❌未找到相关演员"}
+
+    return {"texts": final_msg}
+
+
+async def get_details(query: str, tmdb_lang: Literal["en-US", "zh-CN"] = "zh-CN") -> dict:
+    """Get Movie & TV details by tmdb id.
+
+    query: M597, T34691, P6193
+    """
+    if query.startswith("P"):
+        return await get_people_details(int(query[1:]), tmdb_lang)
+    tmdb_id = query[1:]
+    texts = ""
+    if not query.startswith(("M", "T")):
+        return {"texts": "❌未找到作品详情"}
+
+    media_type = "movie" if query[0].upper() == "M" else "tv"  # M: movie, T: tv
+    url = f"https://api.themoviedb.org/3/{media_type}/{tmdb_id}"
+    params = {"append_to_response": "credits,images", "language": tmdb_lang, "include_image_language": "en,cn,zh"}
+    resp = await hx_req(url, headers=HEADERS, params=params, proxy=PROXY.TMDB, check_kv={"id": tmdb_id}, silent=True)
+    if resp.get("hx_error"):
+        return {"texts": resp["hx_error"]}
+    if title := glom(resp, Coalesce("title", "original_title", "name", "original_name"), default=""):
+        texts += f"标题:《{title}》\n"
+
+    original_title = glom(resp, Coalesce("original_title", "original_name"), default="")
+    if original_title and (original_title != title):
+        texts += f"原名:《{original_title}》\n"
+
+    if subtitle := resp.get("tagline"):
+        texts += f"副标题: {subtitle}\n"
+
+    if genres := glom(resp, "genres.*.name", default=[]):
+        genres_with_tag = [f"#{genre}" for genre in genres]
+        texts += f"类型: {', '.join(genres_with_tag)}\n"
+
+    if date := glom(resp, Coalesce("release_date", "first_air_date"), default=""):
+        texts += f"日期: {date}\n"
+
+    if duration := resp.get("runtime", 0):
+        texts += f"时长: {seconds_to_hms(duration * 60)}\n"
+    if country := resp.get("origin_country", []):
+        texts += f"地区: {'、'.join(country)}\n"
+
+    if rate := resp.get("vote_average", 0):
+        texts += f"评分: {rate}\n"
+
+    if company := glom(resp, "production_companies.*.name", default=[]):
+        texts += f"制片: {'、'.join(company)}\n"
+
+    if imdb_id := resp.get("imdb_id"):
+        texts += f"链接: [TMDB](https://www.themoviedb.org/{media_type}/{resp['id']}), [IMDB](https://www.imdb.com/title/{imdb_id})\n"
+    else:
+        texts += f"链接: [TMDB](https://www.themoviedb.org/{media_type}/{resp['id']})\n"
+    if overview := resp.get("overview"):
+        texts += f"简介: {overview}\n"
+
+    # prefer English Poster
+    media = []
+    img_urls = []
+    posters = defaultdict(list)
+    for poster in glom(resp, "images.posters", default=[]):
+        posters[poster.get("iso_639_1", "unknown")].append(poster)
+    if posters.get("en"):
+        img_urls = [x.get("file_path", "") for x in sorted(posters["en"], key=lambda x: x.get("height", 0), reverse=True)]
+    if posters.get("cn"):
+        img_urls = [x.get("file_path", "") for x in sorted(posters["en"], key=lambda x: x.get("height", 0), reverse=True)]
+    if posters.get("zh"):
+        img_urls = [x.get("file_path", "") for x in sorted(posters["en"], key=lambda x: x.get("height", 0), reverse=True)]
+    img_urls = [f"https://image.tmdb.org/t/p/original{url}" for url in img_urls]
+    # add fallback posters
+    if resp.get("poster_path"):
+        img_urls.append(f"https://image.tmdb.org/t/p/original{resp['poster_path']}")
+    if resp.get("backdrop_path"):
+        img_urls.append(f"https://image.tmdb.org/t/p/original{resp['backdrop_path']}")
+    if img_path := await download_first_success_urls(img_urls, proxy=PROXY.TMDB):
+        media.append({"photo": img_path, "has_spoiler": resp.get("adult", False)})
+
+    # process casts
+    for idx, cast in enumerate(glom(resp, "credits.cast", default=[])):
+        if idx == 0:
+            texts += "\n演员表:\n"
+        emoji = gender_emoji(cast.get("gender"))
+        name = glom(cast, Coalesce("name", "original_name"), default="")
+        if character := cast.get("character"):
+            texts += f"{emoji}[{name}](https://www.themoviedb.org/person/{cast['id']}): {character}\n"
+        else:
+            texts += f"{emoji}[{name}](https://www.themoviedb.org/person/{cast['id']})\n"
+
+    # limit to single message
+    texts = (await smart_split(texts, CAPTION_LENGTH))[0] if media else (await smart_split(texts, TEXT_LENGTH))[0]
+    return {"texts": texts, "media": media}
+
+
+async def get_people_details(people_id: int, tmdb_lang: Literal["en-US", "zh-CN"] = "zh-CN") -> dict:
+    url = f"https://api.themoviedb.org/3/person/{people_id}"
+    params = {"append_to_response": "external_ids,combined_credits,images", "language": tmdb_lang}
+    resp = await hx_req(url, headers=HEADERS, params=params, proxy=PROXY.TMDB, check_kv={"id": people_id}, silent=True)
+
+    if resp.get("hx_error"):
+        return {"texts": resp["hx_error"]}
+    texts = gender_emoji(resp.get("gender", ""))
+    if name := glom(resp, Coalesce("name", "original_name"), default=""):
+        texts += f"{name}"
+    if birth := resp.get("birthday"):
+        texts += f"\n生日: {birth}"
+    if death := resp.get("deathday"):
+        texts += f"\n逝世: {death}"
+    if location := resp.get("place_of_birth"):
+        texts += f"\n出生地: {location}"
+
+    # external links
+    texts += f"\n链接: [TMDB](https://www.themoviedb.org/person/{people_id})"
+    if external_ids := resp.get("external_ids", {}):
+        if imdb_id := external_ids.get("imdb_id"):
+            texts += f", [IMDB](https://www.imdb.com/name/{imdb_id})"
+        if facebook_id := external_ids.get("facebook_id"):
+            texts += f", [FB](https://www.facebook.com/{facebook_id})"
+        if instagram_id := external_ids.get("instagram_id"):
+            texts += f", [Ins](https://www.instagram.com/{instagram_id})"
+        if twitter_id := external_ids.get("twitter_id"):
+            texts += f", [X](https://www.twitter.com/{twitter_id})"
+        if youtube_id := external_ids.get("youtube_id"):
+            texts += f", [油管](https://www.youtube.com/{youtube_id})"
+
+    productions_for_caption = []
+    productions_for_html = ""
+    # process casts
+    casts = [x for x in glom(resp, "combined_credits.cast", default=[]) if x.get("media_type") in ["movie", "tv"]]
+    casts = sorted(casts, key=lambda x: glom(x, Coalesce("release_date", "first_air_date"), default=""), reverse=True)
+    for item in casts:
+        type_initial = item["media_type"][0].upper()  # M: movie, T: tv
+        title = glom(item, Coalesce("title", "original_title", "name", "original_name"), default="")
+        full_date = glom(item, Coalesce("release_date", "first_air_date"), default="")
+        date = full_date[:4] if full_date else ""
+        if date:
+            productions_for_caption.append(f"`{type_initial}{item['id']}`: {title} ({date})\n")
+            productions_for_html += f'<br>{type_initial}{item["id"]}: <a href="https://www.themoviedb.org/{item["media_type"]}/{item["id"]}">{title}</a> ({full_date})'
+        else:
+            productions_for_caption.append(f"`{type_initial}{item['id']}`: {title}\n")
+            productions_for_html += f'<br>{type_initial}{item["id"]}: <a href="https://www.themoviedb.org/{item["media_type"]}/{item["id"]}">{title}</a>'
+        if overview := item.get("overview"):
+            productions_for_html += f"<br>简介: {overview}"
+
+    # process images
+    media = []
+    if images := glom(resp, "images.profiles", default=[]):
+        images = sorted(images, key=lambda x: x.get("width", 0), reverse=True)[:10]  # kepp 10 images
+        media = [{"photo": download_file(f"https://image.tmdb.org/t/p/original{img.get('file_path')}", proxy=PROXY.TMDB)} for img in images]
+        media = await download_media(media)
+    # html = "\n".join([f"<p>{s}</p>" for s in productions_for_html.split("\n")])
+    telegraph_url = await publish_telegraph(title=name, html=productions_for_html.strip("<br>"), author=name, url=f"https://www.themoviedb.org/person/{people_id}")
+
+    description = f"简介: {resp['biography']}" if resp.get("biography") else ""
+    max_length = CAPTION_LENGTH if media else TEXT_LENGTH
+
+    if await count_without_entities(f"{texts}\n{description}") > max_length - 10:  # long desc
+        if telegraph_url:
+            texts += f"\n[查看作品列表]({telegraph_url})"
+        texts = (await smart_split(f"{texts}\n{description}", max_length))[0]
+    else:  # short desc
+        texts += f"\n{description}"
+        texts += f"\n[作品列表]({telegraph_url}):"
+        productions = "".join(productions_for_caption)
+        texts = (await smart_split(f"{texts}\n{productions}", max_length))[0]
+
+    return {"texts": texts, "media": media}
+
+
+def gender_emoji(gender: str | int) -> str:
+    """Gender emoji.
+
+    0: Not set / not specified
+    1: Female
+    2: Male
+    3: Non-binary
+    """
+    if str(gender) == "1":
+        return "🚺"
+    if str(gender) == "2":
+        return "🚹"
+    return ""
src/config.py
@@ -72,6 +72,7 @@ class ENABLE:  # see fine-grained permission in `src/permission.py`
     TTS = os.getenv("ENABLE_TTS", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
     CONVERT_CHINESE = os.getenv("ENABLE_CONVERT_CHINESE", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
     QUOTLY = os.getenv("ENABLE_QUOTLY", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
+    TMDB = os.getenv("ENABLE_TMDB", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
 
 
 class PREFIX:
@@ -99,6 +100,7 @@ class PREFIX:
     CONVERT_TO_TC = os.getenv("PREFIX_CONVERT_TO_TC", "/tc, /tw").lower()
     CONVERT_TO_SC = os.getenv("PREFIX_CONVERT_TO_SC", "/sc, /cn").lower()
     QUOTLY = os.getenv("PREFIX_QUOTLY", "/quote").lower()
+    TMDB = os.getenv("PREFIX_TMDB", "/tmdb").lower()
 
 
 class API:
@@ -160,6 +162,7 @@ class TOKEN:
     SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID", "")
     SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET", "")
     V2EX = os.getenv("V2EX_TOKEN", "")
+    TMDB = os.getenv("TMDB_TOKEN", "")
 
 
 class PROXY:  # format: socks5://127.0.0.1:7890
@@ -184,6 +187,7 @@ class PROXY:  # format: socks5://127.0.0.1:7890
     WEIBO = os.getenv("WEIBO_PROXY", None)
     REDDIT = os.getenv("REDDIT_PROXY", None)
     V2EX = os.getenv("V2EX_PROXY", None)
+    TMDB = os.getenv("TMDB_PROXY", None)
     GITHUB = os.getenv("GITHUB_PROXY", None)
     YTDLP = os.getenv("YTDLP_PROXY", None)  # general proxy for ytdlp
     YTDLP_FALLBACK = os.getenv("YTDLP_PROXY_FALLBACK", None)  # fallback proxy for ytdlp
src/handler.py
@@ -26,6 +26,7 @@ from others.favorite import save_favorite, send_favorite
 from others.raw_img_file import convert_raw_img_file
 from others.search_google import search_google
 from others.search_ytb import search_youtube
+from others.tmdb import search_tmdb
 from permission import check_service
 from preview.bilibili import preview_bilibili
 from preview.douyin import preview_douyin
@@ -70,6 +71,7 @@ async def handle_utilities(
     convert_chinese: bool = True,
     wget: bool = True,
     ytb: bool = True,
+    tmdb: bool = True,
     raw_img: bool = True,
     show_progress: bool = True,
     detail_progress: bool = False,
@@ -100,6 +102,7 @@ async def handle_utilities(
         ocr (bool, optional): Enable OCR. Defaults to True.
         price (bool, optional): Enable Asset price. Defaults to True.
         summary (bool, optional): Enable AI summary. Defaults to True.
+        tmdb (bool, optional): Enable TMDB query. Defaults to True.
         raw_img (bool, optional): Enable convert raw image. Defaults to False.
         show_progress (bool, optional): Show a progress message on Telegram. Defaults to True.
         detail_progress (bool, optional): Show detailed progress (Only if show_proress is set to True). Defaults to False.
@@ -140,6 +143,8 @@ async def handle_utilities(
         await chinese_conversion(client, message, **kwargs)  # /sc
     if quotly:
         await quote_message(client, message, **kwargs)  # /quote
+    if tmdb:
+        await search_tmdb(client, message, **kwargs)  # /tmdb
     if raw_img:
         await convert_raw_img_file(client, message, **kwargs)
 
@@ -210,6 +215,7 @@ async def handle_social_media(
         PREFIX.WGET,
         PREFIX.FAYAN,
         PREFIX.TTS,
+        PREFIX.TMDB,
         PREFIX.CONVERT_TO_SC,
         PREFIX.CONVERT_TO_TC,
         FAVORITE.SAVE_PREFIX,
@@ -407,13 +413,15 @@ def get_social_media_help(chat_id: int | str, ctype: str, prefix: str):
     if permission["ocr"]:
         msg += f"\n🔤**图片转文字**: `{PREFIX.OCR}` + 图片消息"
     if permission["price"]:
-        msg += f"\n💵**查询价格**: `{PREFIX.PRICE}` + Symbol"
+        msg += f"\n💵**查询价格**: `{PREFIX.PRICE}` + symbol"
     if permission["subtitle"]:
         msg += f"\n📃**提取字幕**: `{PREFIX.SUBTITLE}` + B站或油管链接"
     if permission["history"]:
         msg += f"\n🗣**查询聊天记录**: 发送 `{PREFIX.HISTORY}` 查看详细教程"
     if permission["wget"]:
         msg += f"\n⏬**下载文件**: `{PREFIX.WGET}` + URL"
+    if permission["tmdb"]:
+        msg += f"\n🎬**查询影视信息**: `{PREFIX.TMDB}` + 关键词"
     if permission["ytb"]:
         msg += f"\n🔍**搜索YouTube**: `{PREFIX.SEARCH_YOUTUBE}` + 关键词"
     if permission["google"]:
src/permission.py
@@ -145,6 +145,7 @@ def check_service(cid: int | str, ctype: str) -> dict:
         "favorite": True,
         "convert_chinese": True,
         "quotly": True,
+        "tmdb": True,
     } | global_permissions()
 
     if ctype == "PRIVATE":
@@ -210,6 +211,8 @@ def check_service(cid: int | str, ctype: str) -> dict:
         permission["convert_chinese"] = False
     if not ENABLE.QUOTLY:
         permission["quotly"] = False
+    if not ENABLE.TMDB:
+        permission["tmdb"] = False
 
     """
     Set for specific chat