diff --git a/feeluown/gui/assets/themes/common.qss b/feeluown/gui/assets/themes/common.qss index 64e90a4d67..66c9816c4b 100644 --- a/feeluown/gui/assets/themes/common.qss +++ b/feeluown/gui/assets/themes/common.qss @@ -4,7 +4,7 @@ QWidget { /* border: 1px solid red; */ } -LeftPanel QLabel { +LeftPanel QLabel, TriagleButton, PlusButton { color: #888; } diff --git a/feeluown/gui/browser.py b/feeluown/gui/browser.py index 8b452410f6..934ff49a6b 100644 --- a/feeluown/gui/browser.py +++ b/feeluown/gui/browser.py @@ -198,6 +198,7 @@ def initialize(self): render as render_rec_daily_songs from feeluown.gui.pages.my_fav import render as render_my_fav from feeluown.gui.pages.homepage import render as render_homepage + from feeluown.gui.pages.toplist import render as render_toplist model_prefix = f'{MODEL_PAGE_PREFIX}' @@ -218,6 +219,7 @@ async def dummy_render(req, *args, **kwargs): ('/homepage', render_homepage), ('/rec/daily_songs', render_rec_daily_songs), ('/my_fav', render_my_fav), + ('/toplist', render_toplist), ] for url, renderer in urlpatterns: self.route(url)(renderer) diff --git a/feeluown/gui/components/avatar.py b/feeluown/gui/components/avatar.py index 696b6ba940..ee2779748d 100644 --- a/feeluown/gui/components/avatar.py +++ b/feeluown/gui/components/avatar.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING +from PyQt5.QtCore import QRect from PyQt5.QtWidgets import QMenu, QAction from PyQt5.QtGui import QPainter, QIcon, QPalette, QContextMenuEvent @@ -8,14 +9,15 @@ from feeluown.utils.aio import run_afn, run_fn from feeluown.gui.provider_ui import UISupportsLoginOrGoHome, ProviderUiItem, \ UISupportsLoginEvent -from feeluown.gui.widgets import SelfPaintAbstractSquareButton -from feeluown.gui.drawers import PixmapDrawer, AvatarIconDrawer +from feeluown.gui.widgets import SelfPaintAbstractIconTextButton +from feeluown.gui.drawers import SizedPixmapDrawer, AvatarIconDrawer +from feeluown.gui.helpers import painter_save if TYPE_CHECKING: from feeluown.app.gui_app import GuiApp -class Avatar(SelfPaintAbstractSquareButton): +class Avatar(SelfPaintAbstractIconTextButton): """ When no provider is selected, click this button will popup a menu, and let user select a provider. When a provider is selected, click this @@ -24,11 +26,19 @@ class Avatar(SelfPaintAbstractSquareButton): """ def __init__(self, app: 'GuiApp', *args, **kwargs): - super().__init__(*args, **kwargs) + super().__init__('未登录', *args, **kwargs) self._app = app self._avatar_drawer = None - self._icon_drawer = AvatarIconDrawer(self.width(), self._padding) + # In order to make the avatar/icon align to the left edge, + # translate the painter to -self._padding and set different padding + # for avatar-icon and avatar-image. + self._avatar_padding = self._padding // 2 + # Leave 1px to draw line itself. + # Theoretically, the line itself costs 1.5px and only 0.75px is needed. + self._translate_x = 1 - self._padding + self._avatar_translate_x = -self._avatar_padding + self._icon_drawer = AvatarIconDrawer(self.height(), self._padding) self.clicked.connect(self.on_clicked) self.setToolTip('点击登陆资源提供方') @@ -116,28 +126,41 @@ async def _show_provider_current_user(self, source): user = await run_fn(provider.get_current_user_or_none) if user is None: + self._text = '未登录' return None if isinstance(user, UserModel) and user.avatar_url: + self._text = user.name img_data = await run_afn(self._app.img_mgr.get, user.avatar_url, reverse(user)) if img_data: - self._avatar_drawer = PixmapDrawer.from_img_data(img_data, - self, - radius=0.5) + p = self._avatar_padding + w = self.height() - 2 * p + rect = QRect(p, p, w, w) + self._avatar_drawer = SizedPixmapDrawer.from_img_data( + img_data, rect, radius=0.5) return user - def paintEvent(self, _): - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - self.paint_round_bg_when_hover(painter) + def paint_border_bg_when_hover(self, *_, **__): + pass + def draw_text(self, painter): + with painter_save(painter): + if not self._avatar_drawer: + painter.translate(self._translate_x, 0) + super().draw_text(painter) + + def draw_icon(self, painter: QPainter): if self._avatar_drawer: - self._avatar_drawer.draw(painter) + with painter_save(painter): + painter.translate(self._avatar_translate_x, 0) + self._avatar_drawer.draw(painter) else: - # If a provider is selected, draw a highlight circle. - if self._app.current_pvd_ui_mgr.get_either() is not None: - self._icon_drawer.fg_color = self.palette().color(QPalette.Highlight) - self._icon_drawer.draw(painter) + with painter_save(painter): + painter.translate(self._translate_x, 0) + # If a provider is selected, draw a highlight circle. + if self._app.current_pvd_ui_mgr.get_either() is not None: + self._icon_drawer.fg_color = self.palette().color(QPalette.Highlight) + self._icon_drawer.draw(painter) if __name__ == '__main__': @@ -161,4 +184,4 @@ def paintEvent(self, _): 'Hello PyQt5', ) ]) - layout.addWidget(Avatar(mockapp, length=length)) + layout.addWidget(Avatar(mockapp, height=length)) diff --git a/feeluown/gui/drawers.py b/feeluown/gui/drawers.py index c48f7b7a2d..8fb29a3baf 100644 --- a/feeluown/gui/drawers.py +++ b/feeluown/gui/drawers.py @@ -8,17 +8,13 @@ from feeluown.gui.helpers import random_solarized_color, painter_save -class PixmapDrawer: - """Draw pixmap on a widget with radius. - - The pixmap will be scaled to the width of the widget. - """ - def __init__(self, img, widget: QWidget, radius: int = 0): +class SizedPixmapDrawer: + def __init__(self, img, rect: QRect, radius: int = 0): """ :param widget: a object which has width() and height() method. """ - self._widget_last_width = widget.width() - self._widget = widget + self._rect = rect + self._img_old_width = rect.width() self._radius = radius if img is None: @@ -28,9 +24,19 @@ def __init__(self, img, widget: QWidget, radius: int = 0): else: self._img = img self._color = None - new_img = img.scaledToWidth(self._widget_last_width, Qt.SmoothTransformation) + new_img = img.scaledToWidth(self._img_old_width, Qt.SmoothTransformation) self._pixmap = QPixmap(new_img) + def get_radius(self): + return self._radius if self._radius >= 1 else \ + self.get_rect().width() * self._radius + + def get_rect(self): + return self._rect + + def maybe_update_pixmap(self): + pass + @classmethod def from_img_data(cls, img_data, *args, **kwargs): img = QImage() @@ -43,15 +49,7 @@ def get_img(self) -> Optional[QImage]: def get_pixmap(self) -> Optional[QPixmap]: return self._pixmap - def maybe_update_pixmap(self): - if self._widget.width() != self._widget_last_width: - self._widget_last_width = self._widget.width() - assert self._img is not None - new_img = self._img.scaledToWidth(self._widget_last_width, - Qt.SmoothTransformation) - self._pixmap = QPixmap(new_img) - - def draw(self, painter): + def draw(self, painter: QPainter): painter.save() painter.setRenderHint(QPainter.Antialiasing) painter.setRenderHint(QPainter.SmoothPixmapTransform) @@ -61,18 +59,15 @@ def draw(self, painter): self._draw_pixmap(painter) painter.restore() - def _get_radius(self): - return self._radius if self._radius >= 1 else self._widget.width() * self._radius - def _draw_random_color(self, painter: QPainter): brush = QBrush(self._color) painter.setBrush(brush) painter.setPen(Qt.NoPen) - rect = self._widget.rect() - if self._radius == 0: + rect = self.get_rect() + radius = self.get_radius() + if radius == 0: painter.drawRect(rect) else: - radius = self._get_radius() painter.drawRoundedRect(rect, radius, radius) def _draw_pixmap(self, painter: QPainter): @@ -82,20 +77,47 @@ def _draw_pixmap(self, painter: QPainter): brush = QBrush(self._pixmap) painter.setBrush(brush) painter.setPen(Qt.NoPen) - radius = self._radius + radius = self.get_radius() size = self._pixmap.size() - y = (size.height() - self._widget.height()) // 2 + target_rect = self.get_rect() + y = (size.height() - target_rect.height()) // 2 painter.save() + painter.translate(target_rect.x(), target_rect.y()) painter.translate(0, -y) - rect = QRect(0, 0, self._widget.width(), size.height()) + rect = QRect(0, 0, target_rect.width(), size.height()) if radius == 0: painter.drawRect(rect) else: - radius = radius if self._radius >= 1 else self._widget.width() * self._radius painter.drawRoundedRect(rect, radius, radius) painter.restore() +class PixmapDrawer(SizedPixmapDrawer): + """Draw pixmap on a widget with radius. + + The pixmap will be scaled to the width of the widget. + + TODO: rename this drawer to WidgetPixmapDrawer? + """ + def __init__(self, img, widget: QWidget, radius: int = 0): + """ + :param widget: a object which has width() and height() method. + """ + super().__init__(img, widget.rect(), radius) + self._widget = widget + + def get_rect(self): + return self._widget.rect() + + def maybe_update_pixmap(self): + if self._widget.width() != self._img_old_width: + self._img_old_width = self._widget.width() + assert self._img is not None + new_img = self._img.scaledToWidth(self._img_old_width, + Qt.SmoothTransformation) + self._pixmap = QPixmap(new_img) + + class AvatarIconDrawer: def __init__(self, length, padding, fg_color=None): self._length = length @@ -103,7 +125,7 @@ def __init__(self, length, padding, fg_color=None): self.fg_color = fg_color - def draw(self, painter): + def draw(self, painter: QPainter): pen = painter.pen() pen.setWidthF(1.5) painter.setPen(pen) diff --git a/feeluown/gui/pages/homepage.py b/feeluown/gui/pages/homepage.py index 44a66ca6a3..6a5494ac5a 100644 --- a/feeluown/gui/pages/homepage.py +++ b/feeluown/gui/pages/homepage.py @@ -3,7 +3,9 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout -from feeluown.library import SupportsRecListDailyPlaylists, SupportsRecACollectionOfSongs +from feeluown.library import ( + SupportsRecListDailyPlaylists, SupportsRecACollectionOfSongs, Collection, +) from feeluown.utils.reader import create_reader from feeluown.utils.aio import run_fn, as_completed from feeluown.gui.widgets.header import LargeHeader @@ -37,45 +39,51 @@ async def render(req, **kwargs): await view.render() -class View(QWidget, BgTransparentMixin): - - def __init__(self, app: 'GuiApp'): +class Panel(QWidget): + def __init__(self, title, body): super().__init__(parent=None) + + self.header = LargeHeader(title) + self.body = body + + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(10) + self._layout.addWidget(self.header) + self._layout.addWidget(self.body) + + async def render(self): + pass + + +class RecPlaylistsPanel(Panel): + def __init__(self, app): self._app = app - self.header_playlist_list = LargeHeader('一些歌单') - self.header_songs_list = LargeHeader('随便听听') - self.playlist_list_view = PlaylistCardListView(no_scroll_v=True) - self.playlist_list_view.setItemDelegate( + title = '一些歌单' + self.playlist_list_view = playlist_list_view = \ + PlaylistCardListView(no_scroll_v=True) + playlist_list_view.setItemDelegate( PlaylistCardListDelegate( - self.playlist_list_view, + playlist_list_view, card_min_width=150, ) ) - self.songs_list_view = SongMiniCardListView(no_scroll_v=True) - self.songs_list_view.setItemDelegate( - SongMiniCardListDelegate(self.songs_list_view, ) - ) - - self._layout = QVBoxLayout(self) - self._setup_ui() - - self.playlist_list_view.show_playlist_needed.connect( + super().__init__(title, playlist_list_view) + playlist_list_view.show_playlist_needed.connect( lambda model: self._app.browser.goto(model=model) ) - self.songs_list_view.play_song_needed.connect(self._app.playlist.play_model) - def _setup_ui(self): - self._layout.setContentsMargins(20, 10, 20, 0) - self._layout.setSpacing(0) - self._layout.addWidget(self.header_playlist_list) - self._layout.addSpacing(10) - self._layout.addWidget(self.playlist_list_view) - self._layout.addSpacing(10) - self._layout.addWidget(self.header_songs_list) - self._layout.addSpacing(10) - self._layout.addWidget(self.songs_list_view) - self._layout.addStretch(0) + async def render(self): + playlists = await self._get_daily_playlists() + model = PlaylistCardListModel( + create_reader(playlists), fetch_cover_wrapper(self._app), + {p.identifier: p.name + for p in self._app.library.list()} + ) + filter_model = PlaylistFilterProxyModel() + filter_model.setSourceModel(model) + self.playlist_list_view.setModel(filter_model) async def _get_daily_playlists(self): providers = self._app.library.list() @@ -105,39 +113,66 @@ async def _get_daily_playlists(self): break return playlists + +class RecSongsPanel(Panel): + def __init__(self, app): + self._app = app + + title = '随便听听' + self.songs_list_view = songs_list_view = SongMiniCardListView(no_scroll_v=True) + songs_list_view.setItemDelegate( + SongMiniCardListDelegate(songs_list_view, ) + ) + super().__init__(title, songs_list_view) + songs_list_view.play_song_needed.connect(self._app.playlist.play_model) + + async def render(self): + titles, songs = await self._get_rec_songs() + songs_model = SongMiniCardListModel( + create_reader(songs), + fetch_image=fetch_cover_wrapper(self._app), + ) + self.songs_list_view.setModel(songs_model) + if titles: + self.header.setText(titles[0]) + async def _get_rec_songs(self): providers = self._app.library.list() titles = [] songs = [] for coro in as_completed([ - run_fn(provider.rec_a_collection) for provider in providers + run_fn(provider.rec_a_collection_of_songs) for provider in providers if isinstance(provider, SupportsRecACollectionOfSongs) ]): try: - title, songs_ = await coro + coll: Collection = await coro except: # noqa logger.exception('get rec songs failed') else: - songs.extend(songs_) - titles.append(title) + songs.extend(coll.models) + titles.append(coll.name) return titles, songs - async def render(self): - playlists = await self._get_daily_playlists() - model = PlaylistCardListModel( - create_reader(playlists), fetch_cover_wrapper(self._app), - {p.identifier: p.name - for p in self._app.library.list()} - ) - filter_model = PlaylistFilterProxyModel() - filter_model.setSourceModel(model) - self.playlist_list_view.setModel(filter_model) - titles, songs = await self._get_rec_songs() - songs_model = SongMiniCardListModel( - create_reader(songs), - fetch_image=fetch_cover_wrapper(self._app), - ) - self.songs_list_view.setModel(songs_model) - if titles: - self.header_songs_list.setText(titles[0]) +class View(QWidget, BgTransparentMixin): + + def __init__(self, app: 'GuiApp'): + super().__init__(parent=None) + self._app = app + + self.rec_playlist_panel = RecPlaylistsPanel(app) + self.rec_songs_panel = RecSongsPanel(app) + + self._layout = QVBoxLayout(self) + self._setup_ui() + + def _setup_ui(self): + self._layout.setContentsMargins(20, 10, 20, 0) + self._layout.setSpacing(0) + self._layout.addWidget(self.rec_playlist_panel) + self._layout.addWidget(self.rec_songs_panel) + self._layout.addStretch(0) + + async def render(self): + await self.rec_playlist_panel.render() + await self.rec_songs_panel.render() diff --git a/feeluown/gui/pages/recommendation.py b/feeluown/gui/pages/recommendation.py index 3d4944f1ea..973faa43ca 100644 --- a/feeluown/gui/pages/recommendation.py +++ b/feeluown/gui/pages/recommendation.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout +from feeluown.library.provider_protocol import SupportsToplist from feeluown.utils.reader import create_reader from feeluown.utils.aio import run_fn @@ -46,8 +47,6 @@ def __init__(self, app: 'GuiApp'): self.header_title.setText('发现音乐') self.header_playlist_list.setText('个性化推荐') - self.rank_btn.setDisabled(True) - self.rank_btn.setToolTip('未实现,欢迎 PR!') self._layout = QVBoxLayout(self) self._setup_ui() @@ -57,7 +56,7 @@ def __init__(self, app: 'GuiApp'): self.daily_songs_btn.clicked.connect( lambda: self._app.browser.goto(page='/rec/daily_songs')) self.rank_btn.clicked.connect( - lambda: self._app.show_msg('未实现,欢迎 PR!')) + lambda: self._app.browser.goto(page='/toplist')) def _setup_ui(self): self._h_layout = QHBoxLayout() @@ -94,5 +93,5 @@ async def render(self): filter_model.setSourceModel(model) self.playlist_list_view.setModel(filter_model) - if not isinstance(provider, SupportsRecListDailySongs): - self.daily_songs_btn.setDisabled(True) + self.daily_songs_btn.setEnabled(isinstance(provider, SupportsRecListDailySongs)) + self.rank_btn.setEnabled(isinstance(provider, SupportsToplist)) diff --git a/feeluown/gui/pages/toplist.py b/feeluown/gui/pages/toplist.py new file mode 100644 index 0000000000..c4fb38d19f --- /dev/null +++ b/feeluown/gui/pages/toplist.py @@ -0,0 +1,34 @@ +# pylint: disable=duplicate-code +# FIXME: remove duplicate code lator +from typing import TYPE_CHECKING + +from feeluown.library import SupportsToplist +from feeluown.gui.page_containers.table import TableContainer, Renderer +from feeluown.gui.page_containers.scroll_area import ScrollArea +from feeluown.utils.aio import run_fn +from feeluown.utils.reader import create_reader +from .template import render_error_message + + +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp + + +async def render(req, **_): + app: 'GuiApp' = req.ctx['app'] + pvd_ui = app.current_pvd_ui_mgr.get() + if pvd_ui is None: + return await render_error_message(app, '当前资源提供方未知,无法浏览该页面') + + provider = pvd_ui.provider + scroll_area = ScrollArea() + body = TableContainer(app, scroll_area) + scroll_area.setWidget(body) + app.ui.right_panel.set_body(scroll_area) + if isinstance(provider, SupportsToplist): + playlists = await run_fn(provider.toplist_list) + renderer = Renderer() + await body.set_renderer(renderer) + renderer.show_playlists(create_reader(playlists)) + renderer.meta_widget.show() + renderer.meta_widget.title = '排行榜' diff --git a/feeluown/gui/uimain/sidebar.py b/feeluown/gui/uimain/sidebar.py index c0ba2c1fab..373ac298cd 100644 --- a/feeluown/gui/uimain/sidebar.py +++ b/feeluown/gui/uimain/sidebar.py @@ -15,6 +15,7 @@ from feeluown.utils import aio from feeluown.utils.reader import create_reader from feeluown.utils.aio import run_fn +from feeluown.gui.components import Avatar, CollectionListView from feeluown.gui.widgets import ( DiscoveryButton, HomeButton, @@ -22,9 +23,8 @@ TriagleButton, StarButton, ) - +from feeluown.gui.widgets.separator import Separator from feeluown.gui.widgets.playlists import PlaylistsView -from feeluown.gui.components import CollectionListView from feeluown.gui.widgets.my_music import MyMusicView if TYPE_CHECKING: @@ -121,6 +121,8 @@ def __init__(self, app: 'GuiApp', parent=None): self.home_btn = HomeButton(height=30, parent=self) self.discovery_btn = DiscoveryButton(height=30, padding=0.2, parent=self) self.fav_btn = StarButton('我的收藏', height=30, parent=self) + self.fold_top_btn = TriagleButton(length=14, padding=0.2) + self.fold_top_btn.setCheckable(True) self.collections_header = QLabel('本地收藏集', self) self.collections_header.setToolTip('我们可以在本地建立『收藏集』来收藏自己喜欢的音乐资源\n\n' '每个收藏集都以一个独立 .fuo 文件的存在,' @@ -143,23 +145,22 @@ def __init__(self, app: 'GuiApp', parent=None): self.playlists_view.setModel(self._app.pl_uimgr.model) self.my_music_view.setModel(self._app.mymusic_uimgr.model) + self._top_separator = Separator(self._app) self._layout = QVBoxLayout(self) - self._sub_layout = QVBoxLayout() - self._top_layout = QVBoxLayout() + self._avatar_layout = QHBoxLayout() self._layout.setSpacing(0) - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.addLayout(self._top_layout) - self._layout.addLayout(self._sub_layout) - - self._top_layout.setContentsMargins(15, 16, 16, 0) - self._top_layout.addWidget(self.home_btn) - self._top_layout.addWidget(self.discovery_btn) - self._top_layout.addWidget(self.fav_btn) - self._sub_layout.setContentsMargins(16, 8, 16, 0) - self._sub_layout.addWidget(self.collections_con) - self._sub_layout.addWidget(self.my_music_con) - self._sub_layout.addWidget(self.playlists_con) + self._layout.setContentsMargins(16, 10, 16, 0) + self._layout.addWidget(self.home_btn) + self._layout.addWidget(self.collections_con) + self._layout.addWidget(self._top_separator) + self._layout.addLayout(self._avatar_layout) + self._avatar_layout.addWidget(Avatar(self._app, height=48)) + self._avatar_layout.addWidget(self.fold_top_btn) + self._layout.addWidget(self.discovery_btn) + self._layout.addWidget(self.fav_btn) + self._layout.addWidget(self.my_music_con) + self._layout.addWidget(self.playlists_con) self._layout.addStretch(0) self.playlists_view.setFrameShape(QFrame.NoFrame) @@ -173,6 +174,7 @@ def __init__(self, app: 'GuiApp', parent=None): self.discovery_btn.setDisabled(True) self.fav_btn.setDisabled(True) self.discovery_btn.setToolTip('当前资源提供方未知') + self.fold_top_btn.setToolTip('折叠/打开“主页和本地收藏集”功能') if self._app.config.ENABLE_NEW_HOMEPAGE is True: self.home_btn.clicked.connect( @@ -196,6 +198,18 @@ def __init__(self, app: 'GuiApp', parent=None): lambda: self._app.browser.goto(page='/rec')) self.fav_btn.clicked.connect( lambda: self._app.browser.goto(page='/my_fav')) + self.fold_top_btn.clicked.connect(self._toggle_top_layout) + + def _toggle_top_layout(self, checked): + widgets = [self._top_separator, self.collections_con, self.home_btn] + if checked: + self.fold_top_btn.set_direction('down') + for w in widgets: + w.hide() + else: + self.fold_top_btn.set_direction('up') + for w in widgets: + w.show() def popup_collection_adding_dialog(self): dialog = QDialog(self) diff --git a/feeluown/gui/uimain/toolbar.py b/feeluown/gui/uimain/toolbar.py index 4c795d54d0..53e0a16ea1 100644 --- a/feeluown/gui/uimain/toolbar.py +++ b/feeluown/gui/uimain/toolbar.py @@ -3,7 +3,6 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QWidget, QPushButton, QHBoxLayout, QStackedWidget -from feeluown.gui.components import Avatar from feeluown.gui.widgets import ( LeftArrowButton, RightArrowButton, @@ -46,7 +45,6 @@ def __init__(self, app: 'GuiApp', parent=None): self._stack_switch.hide() self.status_line = StatusLine(self._app) - self.avatar = Avatar(app, length=ButtonSize[0]) self.settings_btn = SettingsButton(length=ButtonSize[0]) # initialize widgets @@ -72,7 +70,6 @@ def _setup_ui(self): self._layout.addWidget(self._stack_switch) self._layout.addSpacing(40) self._layout.addWidget(self.status_line) - self._layout.addWidget(self.avatar) self._layout.addWidget(self.settings_btn) # assume the magicbox height is about 30 diff --git a/feeluown/library/provider_protocol.py b/feeluown/library/provider_protocol.py index 8dbc236fc5..7d7fdad3f6 100644 --- a/feeluown/library/provider_protocol.py +++ b/feeluown/library/provider_protocol.py @@ -5,7 +5,7 @@ from .models import ( BriefCommentModel, SongModel, VideoModel, AlbumModel, ArtistModel, PlaylistModel, UserModel, ModelType, BriefArtistModel, BriefSongModel, - LyricModel, BriefVideoModel, + LyricModel, BriefVideoModel, BriefPlaylistModel, ) from .collection import Collection @@ -375,6 +375,22 @@ def current_user_fav_create_videos_rd(self): pass +@runtime_checkable +class SupportsToplist(Protocol): + @abstractmethod + def toplist_list(self) -> List[BriefPlaylistModel]: + """List all toplist(排行榜).""" + + @abstractmethod + def toplist_get(self, toplist_id) -> PlaylistModel: + """Get toplist details. + + For some providers, the toplist model(schema) may be different from the + PlaylistModel. They should think about a way to solve this. For example, + turn the identifier into `toplist_{id}` and do some hack in playlist_get API. + """ + + # # Protocols for recommendation. #