From d8ee8ddd149faed48b54576ec2264527585c36a6 Mon Sep 17 00:00:00 2001 From: Shaowen Yin Date: Tue, 4 Feb 2025 23:34:28 +0800 Subject: [PATCH] [enhance](*) introduce alert manager (#909) - [x] introduce alert manager - [x] show_msg when there is a ConnectTimeout currently - [x] show error message in search page --- feeluown/alert.py | 48 +++++++++++++++++++++++++++++++ feeluown/app/app.py | 5 ++-- feeluown/gui/components/search.py | 22 +++++++++++--- feeluown/gui/widgets/labels.py | 9 ++++-- feeluown/gui/widgets/login.py | 1 + feeluown/library/library.py | 48 ++++++++++++++++++++++--------- feeluown/library/models.py | 2 ++ feeluown/player/playlist.py | 7 +++-- feeluown/task.py | 2 -- 9 files changed, 118 insertions(+), 26 deletions(-) create mode 100644 feeluown/alert.py diff --git a/feeluown/alert.py b/feeluown/alert.py new file mode 100644 index 0000000000..6aacc643a5 --- /dev/null +++ b/feeluown/alert.py @@ -0,0 +1,48 @@ +import logging +from typing import TYPE_CHECKING, Optional +from urllib.parse import urlparse + +from requests import ConnectTimeout + + +if TYPE_CHECKING: + from feeluown.app import App + +logger = logging.getLogger(__name__) + + +class AlertManager: + """Monitor app exceptions and send some alerts.""" + def __init__(self): + # Some alerts handling rely on app and some are not. + self._app: Optional['App'] = None + + def initialize(self, app: 'App'): + """""" + self._app = app + self._app.player.media_loading_failed.connect( + self.on_media_loading_failed, aioqueue=True) + + def on_exception(self, e): + if isinstance(e, ConnectTimeout): + if e.request is not None: + url = e.request.url + hostname = urlparse(url).hostname + else: + hostname = '' + msg = f"链接'{hostname}'超时,请检查你的网络或者代理设置" + self.show_alert(msg) + + def on_media_loading_failed(self, *_): + assert self._app is not None + media = self._app.player.current_media + if media and media.url: + proxy = f' {media.http_proxy}' if media.http_proxy else '空' + hostname = urlparse(media.url).hostname + msg = (f'无法播放来自 {hostname} 的资源(资源的 HTTP 代理为{proxy})' + '(注:播放引擎无法使用系统代理)') + self.show_alert(msg) + + def show_alert(self, alert): + logger.warning(alert) + self._app.show_msg(alert, timeout=2000) diff --git a/feeluown/app/app.py b/feeluown/app/app.py index 19277f4f66..fdc2c02f90 100644 --- a/feeluown/app/app.py +++ b/feeluown/app/app.py @@ -18,6 +18,7 @@ from feeluown.plugin import plugins_mgr from feeluown.version import VersionManager from feeluown.task import TaskManager +from feeluown.alert import AlertManager from .mode import AppMode @@ -47,6 +48,7 @@ def __init__(self, args, config, **kwargs): self.started = Signal() # App is ready to use, for example, UI is available. self.about_to_shutdown = Signal() + self.alert_mgr = AlertManager() self.request = Request() # TODO: rename request to http self.version_mgr = VersionManager(self) self.task_mgr = TaskManager(self) @@ -109,12 +111,11 @@ def __init__(self, args, config, **kwargs): self.about_to_shutdown.connect(lambda _: self.dump_and_save_state(), weak=False) def initialize(self): + self.alert_mgr.initialize(self) self.player_pos_per300ms.initialize() self.player_pos_per300ms.changed.connect(self.live_lyric.on_position_changed) self.playlist.song_changed.connect(self.live_lyric.on_song_changed, aioqueue=True) - self.player.media_loading_failed.connect( - lambda *args: self.show_msg('播放器加载资源失败'), weak=False, aioqueue=True) self.plugin_mgr.enable_plugins(self) def run(self): diff --git a/feeluown/gui/components/search.py b/feeluown/gui/components/search.py index d186efe19d..6dc742d845 100644 --- a/feeluown/gui/components/search.py +++ b/feeluown/gui/components/search.py @@ -78,7 +78,7 @@ def __init__(self, app, **kwargs): self._layout.addStretch(0) async def search_and_render(self, q, search_type, source_in): - # pylint: disable=too-many-locals + # pylint: disable=too-many-locals,too-many-statements view = self app = self._app @@ -88,9 +88,19 @@ async def search_and_render(self, q, search_type, source_in): succeed = 0 start = datetime.now() is_first = True # Is first search result. - view.hint.show_msg('正在搜索...') + if source_in is not None: + source_count = len(source_in) + else: + source_count = len(app.library.list()) + hint_msgs = [f'正在搜索 {source_count} 个资源提供方...'] + view.hint.show_msg('\n'.join(hint_msgs)) async for result in app.library.a_search( - q, type_in=search_type, source_in=source_in): + q, type_in=search_type, source_in=source_in, return_err=True): + if result.err_msg: + hint_msgs.append(f'搜索 {result.source} 的资源出错:{result.err_msg}') + view.hint.show_msg('\n'.join(hint_msgs)) + continue + table_container = TableContainer(app, view.accordion) table_container.layout().setContentsMargins(0, 0, 0, 0) @@ -112,6 +122,8 @@ async def search_and_render(self, q, search_type, source_in): _, search_type, attrname, show_handler = renderer.tabs[tab_index] objects = getattr(result, attrname) or [] if not objects: # Result is empty. + hint_msgs.append(f'搜索 {result.source} 资源,提供方返回空') + view.hint.show_msg('\n'.join(hint_msgs)) continue succeed += 1 @@ -130,7 +142,9 @@ async def search_and_render(self, q, search_type, source_in): renderer.toolbar.hide() is_first = False time_cost = (datetime.now() - start).total_seconds() - view.hint.show_msg(f'搜索完成,共有 {succeed} 个有效的结果,花费 {time_cost:.2f}s') + hint_msgs.pop(0) + hint_msgs.insert(0, f'搜索完成,共有 {succeed} 个有效的结果,花费 {time_cost:.2f}s') + view.hint.show_msg('\n'.join(hint_msgs)) class SearchResultRenderer(Renderer, TabBarRendererMixin): diff --git a/feeluown/gui/widgets/labels.py b/feeluown/gui/widgets/labels.py index 8c8da6203d..9d1ecc407e 100644 --- a/feeluown/gui/widgets/labels.py +++ b/feeluown/gui/widgets/labels.py @@ -1,4 +1,5 @@ from PyQt5.QtCore import QTime, Qt +from PyQt5.QtGui import QPalette, QColor from PyQt5.QtWidgets import QLabel, QSizePolicy from feeluown.utils.utils import parse_ms @@ -83,7 +84,7 @@ class MessageLabel(QLabel): def __init__(self, text='', level=None, *args, **kwargs): super().__init__(*args, **kwargs) - self.setTextFormat(Qt.RichText) + self.setWordWrap(True) self.show_msg(text, level) def show_msg(self, text, level=None): @@ -96,4 +97,8 @@ def show_msg(self, text, level=None): else: hint = '️' color = SOLARIZED_COLORS['blue'] - self.setText(f"{hint}{text}") + palette = self.palette() + palette.setColor(QPalette.Text, QColor(color)) + palette.setColor(QPalette.WindowText, QColor(color)) + self.setPalette(palette) + self.setText(f"{hint}{text}") diff --git a/feeluown/gui/widgets/login.py b/feeluown/gui/widgets/login.py index 21c46939ea..9d7ee4efb3 100644 --- a/feeluown/gui/widgets/login.py +++ b/feeluown/gui/widgets/login.py @@ -90,6 +90,7 @@ def __init__(self, uri=None, required_cookies_fields=None, domain=None): self.cookies_text_edit = QTextEdit(self) self.hint_label = QLabel(self) + self.hint_label.setWordWrap(True) self.login_btn = QPushButton('登录', self) self.weblogin_btn = QPushButton('网页登录', self) self.chrome_btn = QPushButton('从 Chrome 中读取 Cookie') diff --git a/feeluown/library/library.py b/feeluown/library/library.py index fcdfd50e5f..5c570cc769 100644 --- a/feeluown/library/library.py +++ b/feeluown/library/library.py @@ -2,7 +2,6 @@ import logging import warnings from collections import Counter -from functools import partial from typing import Optional, TypeVar, List, TYPE_CHECKING from feeluown.media import Media @@ -16,7 +15,7 @@ ) from feeluown.library.flags import Flags as PF from feeluown.library.models import ( - ModelFlags as MF, BaseModel, + ModelFlags as MF, BaseModel, SimpleSearchResult, BriefVideoModel, BriefSongModel, SongModel, LyricModel, VideoModel, BriefAlbumModel, BriefArtistModel ) @@ -135,30 +134,53 @@ def search(self, keyword, type_in=None, source_in=None, **kwargs): yield result async def a_search(self, keyword, source_in=None, timeout=None, - type_in=None, + type_in=None, return_err=False, **_): """async version of search + .. versionchanged:: 4.1.9 + Add `return_err` parameter. + TODO: add Happy Eyeballs requesting strategy if needed """ type_in = SearchType.batch_parse(type_in) if type_in else [SearchType.so] + # Wrap the search function to associate the result with source. + def wrap_search(pvd, kw, t): + def search(): + try: + res = pvd.search(kw, type_=t) + except Exception as e: # noqa + if return_err: + logger.exception('One provider search failed') + return SimpleSearchResult( + q=keyword, + source=pvd.identifier, # noqa + err_msg=f'{type(e)}', + ) + raise e + # When a provider does not implement search method, it returns None. + if res is not None and ( + res.songs or res.albums or + res.artists or res.videos or res.playlists + ): + return res + return SimpleSearchResult( + q=keyword, source=pvd.identifier, err_msg='结果为空') + return search + fs = [] # future list for provider in self._filter(identifier_in=source_in): for type_ in type_in: - future = run_fn(partial(provider.search, keyword, type_=type_)) + future = run_fn(wrap_search(provider, keyword, type_)) fs.append(future) - - for future in as_completed(fs, timeout=timeout): + for task_ in as_completed(fs, timeout=timeout): try: - result = await future - except: # noqa - logger.exception('search task failed') - continue + result = await task_ + except Exception as e: # noqa + logger.exception('One search task failed due to asyncio') else: - # When a provider does not implement search method, it returns None. - if result is not None: - yield result + yield result async def a_song_prepare_media_no_exc(self, standby, policy): media = None diff --git a/feeluown/library/models.py b/feeluown/library/models.py index a65bf3983b..a2cd5f1183 100644 --- a/feeluown/library/models.py +++ b/feeluown/library/models.py @@ -450,6 +450,8 @@ class SimpleSearchResult(_BaseModel): artists: List[TArtist] = [] playlists: List[TPlaylist] = [] videos: List[TVideo] = [] + source: str = '' + err_msg: str = '' _type_modelcls_mapping = { diff --git a/feeluown/player/playlist.py b/feeluown/player/playlist.py index 1bbf5d9a44..45459610f2 100644 --- a/feeluown/player/playlist.py +++ b/feeluown/player/playlist.py @@ -750,8 +750,9 @@ async def a_play_model(self, model): umodel = await aio.run_fn(upgrade_fn, model) except ModelNotFound: pass - except: # noqa - logger.exception(f'upgrade model:{model} failed') + except Exception as e: # noqa + logger.exception(f'upgrade model({model}) failed') + self._app.alert_mgr.on_exception(e) else: # Replace the brief model with the upgraded model # when user try to play a brief model that is already in the playlist. @@ -766,7 +767,7 @@ async def a_play_model(self, model): fn, model, name=TASK_SET_CURRENT_MODEL ) except: # noqa - logger.exception('play model failed') + logger.exception(f'play model({model}) failed') else: self._app.player.resume() logger.info(f'play a model ({model}) succeed') diff --git a/feeluown/task.py b/feeluown/task.py index 53d440defc..eb44780d00 100644 --- a/feeluown/task.py +++ b/feeluown/task.py @@ -129,14 +129,12 @@ def run_afn_preemptive(self, afn, *args, name=''): if not name: name = get_fn_name(afn) task_spec = self.get_or_create(name) - task_spec.disable_default_cb() return task_spec.bind_coro(afn(*args)) def run_fn_preemptive(self, fn, *args, name=''): if not name: name = get_fn_name(fn) task_spec = self.get_or_create(name) - task_spec.disable_default_cb() return task_spec.bind_blocking_io(fn, *args)