From a8e436977452a669fc5a68cd59c54b8a9a00c81d Mon Sep 17 00:00:00 2001 From: jokinas <89495587+jokinas@users.noreply.github.com> Date: Mon, 18 May 2026 12:31:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E6=9C=BA=E5=88=B6=E5=92=8C=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=20(#873)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style: ruff lint and format fix * fix: 优化登录重试机制和会话处理逻辑 --- newpatch.sh | 0 newversion.sh | 0 update-holiday.sh | 0 update-static-version.py | 0 xiaomusic/auth.py | 159 +++++++++++++++++++++++++++++++------- xiaomusic/conversation.py | 38 +++++---- xiaomusic/xiaomusic.py | 15 +++- 7 files changed, 160 insertions(+), 52 deletions(-) mode change 100755 => 100644 newpatch.sh mode change 100755 => 100644 newversion.sh mode change 100755 => 100644 update-holiday.sh mode change 100755 => 100644 update-static-version.py diff --git a/newpatch.sh b/newpatch.sh old mode 100755 new mode 100644 diff --git a/newversion.sh b/newversion.sh old mode 100755 new mode 100644 diff --git a/update-holiday.sh b/update-holiday.sh old mode 100755 new mode 100644 diff --git a/update-static-version.py b/update-static-version.py old mode 100755 new mode 100644 diff --git a/xiaomusic/auth.py b/xiaomusic/auth.py index 9c425c7..f1cc631 100644 --- a/xiaomusic/auth.py +++ b/xiaomusic/auth.py @@ -7,8 +7,10 @@ - 设备ID更新 """ +import asyncio import json import os +import time from aiohttp import ClientSession from miservice import MiAccount, MiIOService, MiNAService @@ -21,6 +23,9 @@ from xiaomusic.utils.system_utils import ( parse_cookie_string_to_dict, ) +LOGIN_COOLDOWN_SEC = 30 +INIT_LOCK_TIMEOUT_SEC = 60 + class AuthManager: """认证管理器 @@ -39,31 +44,39 @@ class AuthManager: self.log = log self.mi_token_home = os.path.join(self.config.conf_path, ".mi.token") - # 认证状态 + self._init_lock = asyncio.Lock() + self._last_login_time = 0 + self._last_login_ok = False + self.mina_service = None self.miio_service = None self.login_acount = None self.login_password = None self.cookie_jar = None - # 当前设备DID(用于设备ID更新) self._cur_did = None self.device_id = get_random(16).upper() self.mi_session = ClientSession() self.device_manager = device_manager async def init_all_data(self): - """初始化所有数据 + try: + async with asyncio.timeout(INIT_LOCK_TIMEOUT_SEC): + async with self._init_lock: + await self._init_all_data_impl() + except asyncio.TimeoutError: + self.log.warning("init_all_data 超时,可能被其他调用持有锁") - 检查登录状态,如需要则登录,然后更新设备ID和Cookie。 - - """ + async def _init_all_data_impl(self): self.mi_token_home = os.path.join(self.config.conf_path, ".mi.token") is_need_login = await self.need_login() is_can_login = await self.can_login() if is_need_login and is_can_login: self.log.info("try login") - await self.login_miboy() + login_ok = await self.login_miboy() + if not login_ok: + self.log.warning("登录失败,跳过本次初始化") + return else: self.log.info( f"Maybe already logined is_need_login:{is_need_login} is_can_login:{is_can_login}" @@ -85,11 +98,6 @@ class AuthManager: return False async def need_login(self): - """检查是否需要登录 - - Returns: - bool: True表示需要登录,False表示已登录 - """ if self.mina_service is None: return True if self.login_acount != self.config.account: @@ -97,18 +105,26 @@ class AuthManager: if self.login_password != self.config.password: return True + elapsed = time.time() - self._last_login_time + if self._last_login_ok and elapsed < LOGIN_COOLDOWN_SEC: + self.log.debug( + f"最近登录成功且在冷却期内({elapsed:.0f}s/{LOGIN_COOLDOWN_SEC}s),跳过登录检查" + ) + return False + try: await self.mina_service.device_list() except Exception as e: self.log.warning(f"可能登录失败. {e}") + if self._last_login_ok and elapsed < LOGIN_COOLDOWN_SEC * 2: + self.log.warning( + "最近登录成功但API调用失败,可能是临时网络问题,暂不重新登录" + ) + return False return True return False async def login_miboy(self): - """登录小米账号 - - 使用配置的账号密码登录小米账号,并初始化相关服务。 - """ try: mi_account = MiAccount( self.mi_session, @@ -116,24 +132,81 @@ class AuthManager: self.config.password, str(self.mi_token_home), ) - # Forced login to refresh to refresh token self.set_token(mi_account) - await mi_account.login("micoapi") + self._patch_account(mi_account) + login_result = await mi_account.login("micoapi") + if not login_result: + self.mina_service = None + self.miio_service = None + self._last_login_ok = False + self._last_login_time = time.time() + self.log.warning("小米账号登录返回失败,请检查账号密码是否正确") + return False self.mina_service = MiNAService(mi_account) self.miio_service = MiIOService(mi_account) self.login_acount = self.config.account self.login_password = self.config.password + self._last_login_ok = True + self._last_login_time = time.time() self.log.info(f"登录完成. {self.login_acount}") + return True except KeyError as e: self.mina_service = None self.miio_service = None + self._last_login_ok = False + self._last_login_time = time.time() self.log.warning( f"登录失败,API响应格式错误: {e}。建议使用Cookie登录或访问小米官网验证" ) + return False except Exception as e: self.mina_service = None self.miio_service = None + self._last_login_ok = False + self._last_login_time = time.time() self.log.warning(f"可能登录失败. {e}") + return False + + def _patch_account(self, mi_account): + """修补 MiAccount.mi_request 的 401 重试逻辑 + + 原始 mi_request 在收到 401 时会 self.token = None, + 然后内部重新 login,但此时 passToken 已经丢失, + 对于二维码登录(无账号密码)的场景将永远无法恢复。 + + 修补方案:替换 mi_request,401 时从 auth.json 重新加载 passToken, + 然后再尝试 login。 + """ + original_mi_request = mi_account.mi_request + auth_manager = self + + async def patched_mi_request(sid, url, data, headers, relogin=True): + try: + return await original_mi_request(sid, url, data, headers, relogin) + except Exception: + if not relogin: + raise + auth_manager.log.warning( + "mi_request 401 重试失败,尝试从 auth.json 重新加载 passToken" + ) + auth_manager.set_token(mi_account) + if mi_account.token and "passToken" in mi_account.token: + try: + login_ok = await mi_account.login(sid) + if login_ok: + auth_manager.log.info( + "从 auth.json 恢复 passToken 后重新登录成功" + ) + return await original_mi_request( + sid, url, data, headers, False + ) + except Exception as e2: + auth_manager.log.warning( + f"恢复 passToken 后重新登录仍然失败: {e2}" + ) + raise + + mi_account.mi_request = patched_mi_request async def try_update_device_id(self): """更新设备ID @@ -195,27 +268,53 @@ class AuthManager: return def get_cookie(self): - """获取Cookie - - 从配置或token文件中获取Cookie。 - - Returns: - CookieJar: Cookie容器,失败返回None - """ if self.config.cookie: cookie_jar = parse_cookie_string(self.config.cookie) return cookie_jar if not os.path.exists(self.mi_token_home): self.log.warning(f"{self.mi_token_home} file not exist") + cookie_jar = self._get_cookie_from_session() + if cookie_jar: + return cookie_jar return None - with open(self.mi_token_home, encoding="utf-8") as f: - user_data = json.loads(f.read()) - self.log.info("get_cookie user_data loaded") - user_id = user_data.get("userId") - service_token = user_data.get("micoapi")[1] + try: + with open(self.mi_token_home, encoding="utf-8") as f: + user_data = json.loads(f.read()) + self.log.info("get_cookie user_data loaded") + user_id = user_data.get("userId") + service_token = user_data.get("micoapi")[1] + device_id = self.config.get_one_device_id() + cookie_string = COOKIE_TEMPLATE.format( + device_id=device_id, service_token=service_token, user_id=user_id + ) + return parse_cookie_string(cookie_string) + except Exception as e: + self.log.warning(f"读取token文件失败: {e}") + cookie_jar = self._get_cookie_from_session() + if cookie_jar: + return cookie_jar + return None + + def _get_cookie_from_session(self): + if self.mina_service is None: + return None + account = self.mina_service.account + if not account or not account.token: + return None + token = account.token + micoapi_data = token.get("micoapi") + if not micoapi_data or len(micoapi_data) < 2: + self.log.warning("内存token中缺少micoapi数据") + return None + user_id = token.get("userId") + service_token = micoapi_data[1] device_id = self.config.get_one_device_id() + if not user_id or not service_token: + self.log.warning("内存token中缺少userId或serviceToken") + return None + self.log.info("从内存token降级获取cookie成功") cookie_string = COOKIE_TEMPLATE.format( device_id=device_id, service_token=service_token, user_id=user_id ) diff --git a/xiaomusic/conversation.py b/xiaomusic/conversation.py index 911808a..57295e4 100644 --- a/xiaomusic/conversation.py +++ b/xiaomusic/conversation.py @@ -15,6 +15,8 @@ from aiohttp import ClientSession, ClientTimeout from xiaomusic.const import GET_ASK_BY_MINA, LATEST_ASK_API +REINIT_COOLDOWN_SEC = 60 + class ConversationPoller: """对话记录轮询器 @@ -45,10 +47,10 @@ class ConversationPoller: self.device_manager = device_manager self.last_timestamp = {} # key为 did. timestamp last call mi speaker - # 存储最新的对话记录 self.last_record = None - # 内部事件管理 + self._last_reinit_time = 0 + self.polling_event = asyncio.Event() self.new_record_event = asyncio.Event() @@ -153,18 +155,6 @@ class ConversationPoller: raise async def get_latest_ask_from_xiaoai(self, session, device_id): - """从小爱API获取最新对话 - - 通过HTTP请求小爱API获取指定设备的最新对话记录。 - 包含重试机制和错误处理。 - - Args: - session: aiohttp客户端会话 - device_id: 设备ID - - Returns: - None - 通过 _check_last_query 更新内部状态 - """ cookies = {"deviceId": device_id} retries = 3 for i in range(retries): @@ -175,15 +165,12 @@ class ConversationPoller: hardware=hardware, timestamp=str(int(time.time() * 1000)), ) - # self.log.debug(f"url:{url} device_id:{device_id} hardware:{hardware}") r = await session.get(url, timeout=timeout, cookies=cookies) - # 检查响应状态码 if r.status != 200: self.log.warning(f"Request failed with status {r.status}") - # fix #362 if i == 2 and r.status == 401: - await self.auth_manager.init_all_data() + await self._try_reinit("401错误") continue except asyncio.CancelledError: @@ -199,13 +186,24 @@ class ConversationPoller: except Exception as e: self.log.warning(f"Execption {e}") if i == 2: - # tricky way to fix #282 #272 # if it is the third time we re init all data self.log.info("Maybe outof date trying to re init it") - await self.auth_manager.init_all_data() + await self._try_reinit("JSON解析失败") else: return self._get_last_query(device_id, data) self.log.warning("get_latest_ask_from_xiaoai. All retries failed.") + async def _try_reinit(self, reason): + elapsed = time.time() - self._last_reinit_time + if elapsed < REINIT_COOLDOWN_SEC: + self.log.warning( + f"触发reinit冷却中({elapsed:.0f}s/{REINIT_COOLDOWN_SEC}s)," + f"跳过本次reinit(原因: {reason})" + ) + return + self._last_reinit_time = time.time() + self.log.info(f"触发reinit(原因: {reason})") + await self.auth_manager.init_all_data() + async def get_latest_ask_by_mina(self, device_id): """通过Mina服务获取最新对话 diff --git a/xiaomusic/xiaomusic.py b/xiaomusic/xiaomusic.py index e67f64d..9387fbb 100644 --- a/xiaomusic/xiaomusic.py +++ b/xiaomusic/xiaomusic.py @@ -686,11 +686,22 @@ class XiaoMusic: async def getalldevices(self, **kwargs): device_list = [] try: + if self.auth_manager.mina_service is None: + self.log.warning("mina_service 为空,尝试重新初始化") + await self.reinit() + if self.auth_manager.mina_service is None: + self.log.warning("重新初始化后 mina_service 仍为空") + return device_list device_list = await self.auth_manager.mina_service.device_list() except Exception as e: self.log.warning(f"Execption {e}") - # 重新初始化 - await self.reinit() + if not self.auth_manager._last_login_ok: + await self.reinit() + try: + if self.auth_manager.mina_service is not None: + device_list = await self.auth_manager.mina_service.device_list() + except Exception as e2: + self.log.warning(f"重新初始化后获取设备列表仍然失败: {e2}") return device_list async def debug_play_by_music_url(self, arg1=None):