1
0
mirror of https://github.com/hanxi/xiaomusic.git synced 2026-05-20 11:15:46 +08:00

feat: 优化登录重试机制和会话处理逻辑 (#873)

* style: ruff lint and format fix

* fix: 优化登录重试机制和会话处理逻辑
This commit is contained in:
jokinas
2026-05-18 12:31:52 +08:00
committed by GitHub
parent 68224e9afc
commit a8e4369774
7 changed files with 160 additions and 52 deletions

0
newpatch.sh Executable file → Normal file
View File

0
newversion.sh Executable file → Normal file
View File

0
update-holiday.sh Executable file → Normal file
View File

0
update-static-version.py Executable file → Normal file
View File

View File

@@ -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_request401 时从 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
)

View File

@@ -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服务获取最新对话

View File

@@ -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):