├── README.md ├── _conf_schema.json ├── api.py ├── main.py └── metadata.yaml /README.md: -------------------------------------------------------------------------------- 1 | # MoviePilot订阅 2 | 3 | > 本项目是基于AstrBot开发的插件,部署请参考此项目 4 | > https://github.com/Soulter/AstrBot 5 | 6 | ## 配置 7 | 8 | - mp_url:公网能够访问的mp地址 9 | - mp_token:mp中的token 10 | - mp_username:mp的用户名 11 | - mp_password:mp的密码 12 | 13 | ## 指令 14 | 15 | - /sub 片名 订阅影片 16 | - /download 查看下载 17 | 18 | -------------------------------------------------------------------------------- /_conf_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "mp_url": { 3 | "description": "MoviePilotUrl", 4 | "type": "string" 5 | }, 6 | "mp_token": { 7 | "description": "MoviePilotToken", 8 | "type": "string" 9 | }, 10 | "mp_username": { 11 | "description": "MoviePilot用户名", 12 | "type": "string" 13 | }, 14 | "mp_password": { 15 | "description": "MoviePilot密码", 16 | "type": "string" 17 | } 18 | } -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from typing import List 3 | from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult 4 | from astrbot.api.star import Context, Star, register 5 | from astrbot.api import logger 6 | import httpx 7 | 8 | class MoviepilotApi: 9 | def __init__(self, config: dict): 10 | self.base_url = config.get('mp_url') 11 | self.mp_username = config.get('mp_username') 12 | self.mp_password = config.get('mp_password') 13 | print(self.mp_username) 14 | 15 | async def _get_mp_token(self) -> str | None: 16 | _api_path = "/api/v1/login/access-token" 17 | headers = { 18 | "Content-Type": "application/x-www-form-urlencoded", 19 | "accept": "application/json" 20 | } 21 | # 构建表单数据 22 | form_data = { 23 | "username": self.mp_username, 24 | "password": self.mp_password, 25 | } 26 | 27 | if self.mp_password is None: 28 | logger.error("moviepilot的密码不能为空") 29 | return "" 30 | else: 31 | # 发送 POST 请求并传递表单数据 32 | data = await self._request( 33 | url=self.base_url + _api_path, 34 | method="POST-DATA", 35 | headers=headers, 36 | data=form_data 37 | ) 38 | return data.get("access_token", None) if data else None 39 | 40 | async def _get_headers(self) -> dict[str, str] | None: 41 | _token = await self._get_mp_token() 42 | if _token: 43 | return { 44 | "Authorization": f"Bearer {_token}", 45 | 'User-Agent': "nonebot2/0.0.1" 46 | } 47 | else: 48 | logger.error("访问MoviePilot失败,请确认密码或者是否开启了两步验证") 49 | return 50 | 51 | async def search_media_info(self, media_name: str) -> dict | None: 52 | _api_path = f"/api/v1/media/search?title={media_name}" 53 | try: 54 | return await self._request( 55 | url=self.base_url + _api_path, 56 | method="GET", 57 | headers=await self._get_headers() 58 | ) 59 | except Exception as e: 60 | logger.error(f"Error searching movies: {e}\n{traceback.format_exc()}") 61 | return None 62 | 63 | async def list_all_seasons(self, tmdbid: str) -> dict | None: 64 | _api_path = f"/api/v1/tmdb/seasons/{tmdbid}" 65 | try: 66 | return await self._request( 67 | url=self.base_url + _api_path, 68 | method="GET", 69 | headers=await self._get_headers() 70 | ) 71 | except Exception as e: 72 | logger.error(f"Error listing seasons: {e}") 73 | return None 74 | 75 | async def subscribe_movie(self, movie: dict) -> bool: 76 | _api_path = "/api/v1/subscribe/" 77 | body = { 78 | "name": movie['title'], 79 | "tmdbid": movie['tmdb_id'], 80 | "type": "电影" 81 | } 82 | try: 83 | response = await self._request( 84 | url=self.base_url + _api_path, 85 | method="POST-JSON", 86 | headers=await self._get_headers(), 87 | data=body 88 | ) 89 | logger.info(response) 90 | return response.get("success", False) if response else False 91 | except Exception as e: 92 | logger.error(f"Error subscribing to movie: {e}") 93 | return False 94 | 95 | async def subscribe_series(self, movie: dict, season: int) -> bool: 96 | _api_path = "/api/v1/subscribe/" 97 | body = { 98 | "name": movie['title'], 99 | "tmdbid": movie['tmdb_id'], 100 | "season": season 101 | } 102 | try: 103 | response = await self._request( 104 | url=self.base_url + _api_path, 105 | method="POST-JSON", 106 | headers=await self._get_headers(), 107 | data=body 108 | ) 109 | return response.get("success", False) if response else False 110 | except Exception as e: 111 | logger.error(f"Error subscribing to series: {e}") 112 | return False 113 | 114 | async def _request( 115 | self, 116 | url, 117 | method="GET", 118 | headers=None, 119 | data=None 120 | ) -> List | None: 121 | 122 | if headers is None: 123 | headers = {'user-agent': 'nonebot2/0.0.1'} 124 | timeout = httpx.Timeout(120.0, read=120.0) 125 | 126 | logger.info(f""" 127 | url: {url} 128 | method = {method} 129 | headers = {headers} 130 | data = {data} 131 | """) 132 | 133 | async with httpx.AsyncClient(timeout=timeout) as client: 134 | if method == "GET": 135 | r = await client.get(url, headers=headers) 136 | elif method == "POST-JSON": 137 | r = await client.post(url, headers=headers, json=data) 138 | elif method == "POST-DATA": 139 | r = await client.post(url, headers=headers, data=data) 140 | else: 141 | return 142 | 143 | if r.status_code != 200: 144 | logger.error(f"{r.status_code} 请求错误\n{r}") 145 | else: 146 | return r.json() 147 | 148 | async def get_download_progress(self) -> List[dict] | None: 149 | """获取下载进度 150 | Returns: 151 | List[dict] | None: 返回下载任务列表,每个任务包含以下字段: 152 | - media: dict 媒体信息 153 | - title: str 中文标题 154 | - type: str 类型(电影/电视剧) 155 | - progress: float 下载进度(百分比) 156 | - state: str 下载状态 157 | """ 158 | _api_path = "/api/v1/download/" 159 | try: 160 | headers = await self._get_headers() 161 | if not headers: 162 | logger.error("获取认证头失败") 163 | return None 164 | 165 | data = await self._request( 166 | url=self.base_url + _api_path, 167 | method="GET", 168 | headers=headers 169 | ) 170 | 171 | if not data: 172 | logger.info("当前没有正在下载的任务") 173 | return [] 174 | 175 | return data 176 | 177 | except Exception as e: 178 | logger.error(f"获取下载进度失败: {e}") 179 | return None 180 | 181 | 182 | 183 | 184 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult 2 | from astrbot.api.star import Context, Star, register 3 | from astrbot.api import logger 4 | import time 5 | import astrbot.api.message_components as Comp 6 | from astrbot.core.utils.session_waiter import ( 7 | session_waiter, 8 | SessionController, 9 | ) 10 | from .api import MoviepilotApi 11 | 12 | @register("MoviepilotSubscribe", "4Nest", "MoviepilotQQ机器人订阅 插件", "1.1.1", "https://github.com/4Nest/astrbot_plugin_mp_sub") 13 | class MyPlugin(Star): 14 | def __init__(self, context: Context, config: dict): 15 | super().__init__(context) 16 | self.config = config 17 | self.api = MoviepilotApi(config) # 将 api 定义为实例属性 18 | self.state = {} # 初始化状态管理字典 19 | print(self.config) 20 | 21 | @filter.command("sub") 22 | async def sub(self, event: AstrMessageEvent, message: str): 23 | '''订阅影片''' 24 | movies = await self.api.search_media_info(message) # 使用 self.api 访问实例属性 25 | if movies: 26 | movie_list = "\n".join([f"{i + 1}. {movie['title']} ({movie['year']})" for i, movie in enumerate(movies)]) 27 | print(movie_list) 28 | media_list = "\n查询到的影片如下\n请直接回复序号进行订阅(回复0退出选择):\n" + movie_list 29 | yield event.plain_result(media_list) 30 | 31 | # 使用会话控制器等待用户回复 32 | @session_waiter(timeout=60, record_history_chains=False) 33 | async def movie_selection_waiter(controller: SessionController, event: AstrMessageEvent): 34 | try: 35 | user_input = event.message_str.strip() 36 | user_id = event.get_sender_id() 37 | 38 | # 检查用户是否在等待选择季度 39 | user_state = self.state.get(user_id, {}) 40 | if user_state.get("waiting_for") == "season": 41 | # 用户正在选择季度 42 | try: 43 | season_number = int(user_input) 44 | selected_movie = user_state["selected_movie"] 45 | seasons = user_state["seasons"] 46 | 47 | # 验证季度是否有效 48 | valid_season = False 49 | for season in seasons: 50 | if season['season_number'] == season_number: 51 | valid_season = True 52 | break 53 | 54 | if valid_season: 55 | # 订阅电视剧的指定季度 56 | success = await self.api.subscribe_series(selected_movie, season_number) 57 | message_result = event.make_result() 58 | if success: 59 | message_result.chain = [Comp.Plain(f"\n订阅类型:{selected_movie['type']}\n订阅影片:{selected_movie['title']} ({selected_movie['year']})\n订阅第 {season_number} 季成功!")] 60 | else: 61 | message_result.chain = [Comp.Plain("订阅失败。")] 62 | await event.send(message_result) 63 | # 清除状态 64 | self.state.pop(user_id, None) 65 | controller.stop() 66 | else: 67 | message_result = event.make_result() 68 | message_result.chain = [Comp.Plain("无效的季数,请重新输入。")] 69 | await event.send(message_result) 70 | controller.keep(timeout=60, reset_timeout=True) 71 | except ValueError: 72 | message_result = event.make_result() 73 | message_result.chain = [Comp.Plain("请输入一个有效的季数。")] 74 | await event.send(message_result) 75 | controller.keep(timeout=60, reset_timeout=True) 76 | return 77 | 78 | # 处理电影选择 79 | try: 80 | index = int(user_input) - 1 81 | 82 | if index == -1: # 用户输入0 83 | message_result = event.make_result() 84 | message_result.chain = [Comp.Plain("操作已取消。")] 85 | await event.send(message_result) 86 | controller.stop() 87 | return 88 | 89 | if 0 <= index < len(movies): 90 | selected_movie = movies[index] 91 | if selected_movie['type'] == "电视剧": 92 | # 如果是电视剧,获取所有季数 93 | seasons = await self.api.list_all_seasons(selected_movie['tmdb_id']) 94 | if seasons: 95 | season_list = "\n".join( 96 | [f"第 {season['season_number']} 季 {season['name']}" for season in seasons]) 97 | season_list = "\n查询到的季如下\n请直接回复季数进行选择:\n" + season_list 98 | 99 | message_result = event.make_result() 100 | message_result.chain = [Comp.Plain(season_list)] 101 | await event.send(message_result) 102 | 103 | # 继续等待用户选择季数 104 | controller.keep(timeout=60, reset_timeout=True) 105 | 106 | # 更新状态 107 | self.state[user_id] = { 108 | "selected_movie": selected_movie, 109 | "seasons": seasons, 110 | "waiting_for": "season" 111 | } 112 | else: 113 | message_result = event.make_result() 114 | message_result.chain = [Comp.Plain("没有找到可用的季数。")] 115 | await event.send(message_result) 116 | controller.stop() 117 | else: 118 | # 如果是电影,直接订阅 119 | success = await self.api.subscribe_movie(selected_movie) 120 | message_result = event.make_result() 121 | if success: 122 | message_result.chain = [Comp.Plain(f"\n订阅类型:{selected_movie['type']}\n订阅影片:{selected_movie['title']} ({selected_movie['year']})\n订阅成功!")] 123 | else: 124 | message_result.chain = [Comp.Plain("订阅失败。")] 125 | await event.send(message_result) 126 | controller.stop() 127 | else: 128 | message_result = event.make_result() 129 | message_result.chain = [Comp.Plain("无效的序号,请重新输入。")] 130 | await event.send(message_result) 131 | controller.keep(timeout=60, reset_timeout=True) 132 | except ValueError: 133 | message_result = event.make_result() 134 | message_result.chain = [Comp.Plain("请输入一个数字。")] 135 | await event.send(message_result) 136 | controller.keep(timeout=60, reset_timeout=True) 137 | except Exception as e: 138 | logger.error(f"处理用户输入时出错: {e}") 139 | message_result = event.make_result() 140 | message_result.chain = [Comp.Plain(f"处理输入时出错: {str(e)}")] 141 | await event.send(message_result) 142 | controller.stop() 143 | 144 | try: 145 | await movie_selection_waiter(event) 146 | except Exception as e: 147 | logger.error(f"Movie selection error: {e}") 148 | yield event.plain_result(f"发生错误:{str(e)}") 149 | finally: 150 | event.stop_event() 151 | else: 152 | yield event.plain_result("没有查询到影片,请检查名字。") 153 | 154 | @filter.command("download") 155 | async def progress(self, event: AstrMessageEvent): 156 | '''查看下载''' 157 | progress_data = await self.api.get_download_progress() 158 | if progress_data is not None: # 如果成功获取到数据 159 | if len(progress_data) == 0: # 如果没有正在下载的任务 160 | yield event.plain_result("当前没有正在下载的任务。") 161 | return 162 | 163 | # 格式化下载进度信息 164 | progress_list = [] 165 | for task in progress_data: 166 | media = task.get('media', {}) 167 | title = media.get('title', task.get('title', '未知')) 168 | season = media.get('season', '') 169 | episode = media.get('episode', '') 170 | progress = round(task.get('progress', 0), 2) # 保留两位小数 171 | 172 | # 按照要求格式化:title season episode:progress 173 | formatted_info = f"{title} {season} {episode}:{progress}%" 174 | progress_list.append(formatted_info) 175 | 176 | result = "\n".join(progress_list) 177 | yield event.plain_result(result) 178 | else: 179 | yield event.plain_result("获取下载进度失败,请稍后重试。") 180 | -------------------------------------------------------------------------------- /metadata.yaml: -------------------------------------------------------------------------------- 1 | name: moviepilot_sub # 这是你的插件的唯一识别名。 2 | desc: Moviepilot订阅 # 插件简短描述 3 | help: # 插件的帮助信息 4 | version: v1.1.1 # 插件版本号。格式:v1.1.1 或者 v1.1 5 | author: 4Nest # 作者 6 | repo: https://github.com/4Nest/astrbot_plugin_mp_sub # 插件的仓库地址 7 | --------------------------------------------------------------------------------