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:
0
newpatch.sh
Executable file → Normal file
0
newpatch.sh
Executable file → Normal file
0
newversion.sh
Executable file → Normal file
0
newversion.sh
Executable file → Normal file
0
update-holiday.sh
Executable file → Normal file
0
update-holiday.sh
Executable file → Normal file
0
update-static-version.py
Executable file → Normal file
0
update-static-version.py
Executable file → Normal 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_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
|
||||
)
|
||||
|
||||
@@ -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服务获取最新对话
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user