diff --git a/examples/model_display.py b/examples/model_display.py deleted file mode 100755 index 6ccd4f8213..0000000000 --- a/examples/model_display.py +++ /dev/null @@ -1,29 +0,0 @@ -#! /usr/bin/env python3 -# -*- coding: utf-8 -*- - -import logging - -from fuo_netease.models import NSongModel - -logging.basicConfig() -logger = logging.getLogger('feeluown') -logger.setLevel(logging.DEBUG) - - -def test_model_display(): - song = NSongModel.create_by_display( - identifier=254548, - title='成全', - artists_name='刘若英') - assert song.album_name_display == '' - assert song.title_display == '成全' - print(song.url, song.title) - assert song.album_name_display != '' - - -def main(): - test_model_display() - - -if __name__ == '__main__': - main() diff --git a/feeluown/app/app.py b/feeluown/app/app.py index ad379b0ca6..4b9b69375f 100644 --- a/feeluown/app/app.py +++ b/feeluown/app/app.py @@ -7,7 +7,7 @@ from feeluown.utils.request import Request from feeluown.library import Library from feeluown.utils.dispatch import Signal -from feeluown.models import ( +from feeluown.library import ( Resolver, reverse, resolve, ResolverNotFound, ResolveFailed, ) @@ -139,6 +139,8 @@ def apply_state(self, state): song = resolve(song) except ResolverNotFound: pass + except ResolveFailed as e: + logger.warning(f'resolve failed, {e}') else: songs.append(song) playlist.set_models(songs) diff --git a/feeluown/collection.py b/feeluown/collection.py index 2c48fac4fa..082e36637c 100644 --- a/feeluown/collection.py +++ b/feeluown/collection.py @@ -11,8 +11,8 @@ from feeluown.consts import COLLECTIONS_DIR from feeluown.utils.dispatch import Signal -from feeluown.models.uri import resolve, reverse, ResolverNotFound, \ - ResolveFailed, ModelExistence +from feeluown.library import resolve, reverse, ResolverNotFound, \ + ResolveFailed, ModelState from feeluown.utils.utils import elfhash logger = logging.getLogger(__name__) @@ -112,7 +112,7 @@ def load(self): str(filepath), line, str(e)) model = None if model is not None: - if model.exists is ModelExistence.no: + if model.state is ModelState.not_exists: self._has_nonexistent_models = True self.models.append(model) @@ -140,7 +140,7 @@ def create_empty(cls, fpath, title=''): def add(self, model): """add model to collection - :param model: :class:`feeluown.models.BaseModel` + :param model: :class:`feeluown.library.BaseModel` :return: True means succeed, False means failed """ if model not in self.models: @@ -184,7 +184,8 @@ def on_provider_added(self, provider): if not self._has_nonexistent_models: return for i, model in enumerate(self.models.copy()): - if model.exists is ModelExistence.no and model.source == provider.identifier: + if model.state is ModelState.not_exists and \ + model.source == provider.identifier: new_model = resolve(reverse(model, as_line=True)) # TODO: emit data changed signal self.models[i] = new_model @@ -193,7 +194,7 @@ def on_provider_added(self, provider): def on_provider_removed(self, provider): for model in self.models: if model.source == provider.identifier: - model.exists = ModelExistence.no + model.state = ModelState.not_exists self._has_nonexistent_models = True def _loads_metadata(self, metadata): diff --git a/feeluown/gui/base_renderer.py b/feeluown/gui/base_renderer.py index 5502486418..c206537c8d 100644 --- a/feeluown/gui/base_renderer.py +++ b/feeluown/gui/base_renderer.py @@ -4,7 +4,7 @@ from abc import abstractmethod from typing import runtime_checkable, Protocol -from feeluown.models import ModelType +from feeluown.library import ModelType from feeluown.gui.widgets.tabbar import Tab, TabBar diff --git a/feeluown/gui/browser.py b/feeluown/gui/browser.py index 024e85e257..3e588707f9 100644 --- a/feeluown/gui/browser.py +++ b/feeluown/gui/browser.py @@ -7,7 +7,7 @@ from feeluown.utils import aio from feeluown.utils.router import Router, NotFound -from feeluown.models.uri import resolve, reverse, ResolveFailed, parse_line +from feeluown.library import resolve, reverse, ResolveFailed, parse_line logger = logging.getLogger(__name__) diff --git a/feeluown/gui/components/avatar.py b/feeluown/gui/components/avatar.py index df9e808ad3..fbeaec50a9 100644 --- a/feeluown/gui/components/avatar.py +++ b/feeluown/gui/components/avatar.py @@ -4,7 +4,7 @@ from PyQt5.QtGui import QPainter, QIcon, QPalette, QContextMenuEvent from feeluown.library import NoUserLoggedIn, UserModel -from feeluown.models.uri import reverse +from feeluown.library import reverse from feeluown.utils.aio import run_afn, run_fn from feeluown.gui.provider_ui import UISupportsLoginOrGoHome, ProviderUiItem, \ UISupportsLoginEvent diff --git a/feeluown/gui/components/song_tag.py b/feeluown/gui/components/song_tag.py index 3b19b0c0d4..dc5f1db9ed 100644 --- a/feeluown/gui/components/song_tag.py +++ b/feeluown/gui/components/song_tag.py @@ -62,13 +62,13 @@ def on_metadata_changed(self, metadata): async def _switch_provider(self, provider_id): song = self._app.playlist.current_song - songs = await self._app.library.a_list_song_standby( + songs = await self._app.library.a_list_song_standby_v2( song, source_in=[provider_id]) if songs: - standby = songs[0] + standby, media = songs[0] assert standby != song self._app.show_msg(f'使用 {standby} 替换当前歌曲') - self._app.playlist.pure_set_current_song(standby, standby.url) + self._app.playlist.pure_set_current_song(standby, media) self._app.playlist.remove(song) else: self._app.show_msg(f'提供方 “{provider_id}” 没有找到可用的相似歌曲') diff --git a/feeluown/gui/helpers.py b/feeluown/gui/helpers.py index 8ac4100de2..5ece454120 100644 --- a/feeluown/gui/helpers.py +++ b/feeluown/gui/helpers.py @@ -39,7 +39,7 @@ from feeluown.utils.typing_ import Protocol from feeluown.excs import ProviderIOError, ResourceNotFound from feeluown.library import NotSupported, ModelType, BaseModel -from feeluown.models.uri import reverse +from feeluown.library import reverse if TYPE_CHECKING: diff --git a/feeluown/gui/mimedata.py b/feeluown/gui/mimedata.py index 2ad1bd6303..b2a4c6595f 100644 --- a/feeluown/gui/mimedata.py +++ b/feeluown/gui/mimedata.py @@ -1,6 +1,6 @@ from PyQt5.QtCore import QMimeData -from feeluown.models import ModelType +from feeluown.library import ModelType model_mimetype_map = { diff --git a/feeluown/gui/pages/coll_mixed.py b/feeluown/gui/pages/coll_mixed.py index 49f567dd56..318cc55d4d 100644 --- a/feeluown/gui/pages/coll_mixed.py +++ b/feeluown/gui/pages/coll_mixed.py @@ -1,6 +1,6 @@ from feeluown.app.gui_app import GuiApp from feeluown.collection import CollectionType, Collection -from feeluown.models import ModelType +from feeluown.library import ModelType from feeluown.utils.reader import wrap from feeluown.gui.page_containers.table import Renderer diff --git a/feeluown/gui/pages/model.py b/feeluown/gui/pages/model.py index 496d661f10..38a021b384 100644 --- a/feeluown/gui/pages/model.py +++ b/feeluown/gui/pages/model.py @@ -1,7 +1,7 @@ from feeluown.library import V2SupportedModelTypes, AlbumModel, NotSupported from feeluown.utils import aio from feeluown.utils.reader import create_reader -from feeluown.models import ModelType, reverse +from feeluown.library import ModelType, reverse from feeluown.gui.base_renderer import TabBarRendererMixin from feeluown.gui.page_containers.table import Renderer diff --git a/feeluown/gui/pages/my_fav.py b/feeluown/gui/pages/my_fav.py index c82884612c..d86812a7e0 100644 --- a/feeluown/gui/pages/my_fav.py +++ b/feeluown/gui/pages/my_fav.py @@ -1,5 +1,5 @@ from feeluown.app.gui_app import GuiApp -from feeluown.models import ModelType +from feeluown.library import ModelType from feeluown.utils.aio import run_fn from feeluown.gui.page_containers.table import Renderer from feeluown.gui.base_renderer import TabBarRendererMixin diff --git a/feeluown/gui/pages/search.py b/feeluown/gui/pages/search.py index ec9cb7c427..62967c21d0 100644 --- a/feeluown/gui/pages/search.py +++ b/feeluown/gui/pages/search.py @@ -1,6 +1,6 @@ from PyQt5.QtWidgets import QFrame, QVBoxLayout -from feeluown.models import SearchType +from feeluown.library import SearchType from feeluown.gui.page_containers.table import TableContainer, Renderer from feeluown.gui.page_containers.scroll_area import ScrollArea from feeluown.gui.widgets.img_card_list import ImgCardListDelegate diff --git a/feeluown/gui/pages/song_explore.py b/feeluown/gui/pages/song_explore.py index f7f6a1dc94..9feff46b76 100644 --- a/feeluown/gui/pages/song_explore.py +++ b/feeluown/gui/pages/song_explore.py @@ -14,7 +14,7 @@ NotSupported, ModelFlags ) from feeluown.player import Lyric -from feeluown.models.uri import reverse, resolve +from feeluown.library import reverse, resolve from feeluown.utils import aio from feeluown.utils.aio import run_afn from feeluown.utils.reader import create_reader diff --git a/feeluown/gui/uimain/page_view.py b/feeluown/gui/uimain/page_view.py index 09194e2bda..71969549bf 100644 --- a/feeluown/gui/uimain/page_view.py +++ b/feeluown/gui/uimain/page_view.py @@ -6,7 +6,7 @@ from PyQt5.QtWidgets import QFrame, QVBoxLayout, QStackedLayout from feeluown.utils import aio -from feeluown.models import ModelType +from feeluown.library import ModelType from feeluown.utils.reader import wrap from feeluown.gui.theme import Light diff --git a/feeluown/gui/uimodels/playlist.py b/feeluown/gui/uimodels/playlist.py index abe25a851a..48ced204ca 100644 --- a/feeluown/gui/uimodels/playlist.py +++ b/feeluown/gui/uimodels/playlist.py @@ -5,7 +5,7 @@ from feeluown.gui.widgets.playlists import PlaylistsModel -from feeluown.models import PlaylistModel +from feeluown.library import PlaylistModel class PlaylistUiItem(PlaylistModel): diff --git a/feeluown/gui/widgets/img_card_list.py b/feeluown/gui/widgets/img_card_list.py index 0072cd1c86..3a9a937823 100644 --- a/feeluown/gui/widgets/img_card_list.py +++ b/feeluown/gui/widgets/img_card_list.py @@ -29,7 +29,7 @@ from feeluown.utils import aio from feeluown.library import AlbumModel, AlbumType from feeluown.utils.reader import wrap -from feeluown.models.uri import reverse +from feeluown.library import reverse from feeluown.gui.helpers import ( ItemViewNoScrollMixin, resize_font, ReaderFetchMoreMixin, painter_save, secondary_text_color diff --git a/feeluown/gui/widgets/login.py b/feeluown/gui/widgets/login.py index 94957cb500..e37493fb8f 100644 --- a/feeluown/gui/widgets/login.py +++ b/feeluown/gui/widgets/login.py @@ -185,7 +185,7 @@ def autologin(self): def setup_user(self, user): """Setup user session - :type user: feeluown.models.UserModel + :type user: feeluown.library.UserModel """ raise NotImplementedError @@ -193,7 +193,7 @@ async def user_from_cookies(self, cookies): """Create a user model from cookies dict :type cookies: dict - :rtype: feeluown.models.UserModel + :rtype: feeluown.library.UserModel """ raise NotImplementedError @@ -214,7 +214,7 @@ def dump_user_cookies(self, user, cookies): Generally, you can store the cookies in FeelUOwn data directory with specifical filename. - :type user: feeluown.models.UserModel + :type user: feeluown.library.UserModel :type cookies: dict """ raise NotImplementedError diff --git a/feeluown/gui/widgets/song_minicard_list.py b/feeluown/gui/widgets/song_minicard_list.py index ce79d49033..663dd0d6cf 100644 --- a/feeluown/gui/widgets/song_minicard_list.py +++ b/feeluown/gui/widgets/song_minicard_list.py @@ -14,7 +14,7 @@ ) from feeluown.utils import aio -from feeluown.models.uri import reverse +from feeluown.library import reverse from feeluown.gui.helpers import ( ItemViewNoScrollMixin, ReaderFetchMoreMixin, resize_font, SOLARIZED_COLORS ) diff --git a/feeluown/gui/widgets/songs.py b/feeluown/gui/widgets/songs.py index eed8c78e63..f3e8a5dbee 100644 --- a/feeluown/gui/widgets/songs.py +++ b/feeluown/gui/widgets/songs.py @@ -17,8 +17,7 @@ from feeluown.utils import aio from feeluown.utils.dispatch import Signal -from feeluown.library import ModelState, ModelFlags -from feeluown.models import ModelExistence +from feeluown.library import ModelState from feeluown.gui.mimedata import ModelMimeData from feeluown.gui.helpers import ItemViewNoScrollMixin, ReaderFetchMoreMixin @@ -250,12 +249,8 @@ def flags(self, index): # If song's state is `not_exists` or `cant_upgrade`, the album and # artist columns are disabled. incomplete = False - if ModelFlags.v2 & song.meta.flags: - if song.state in (ModelState.not_exists, ModelState.cant_upgrade): - incomplete = True - else: - if song and song.exists == ModelExistence.no: - incomplete = True + if song.state in (ModelState.not_exists, ModelState.cant_upgrade): + incomplete = True if incomplete: if index.column() != Column.song: flags = no_item_flags diff --git a/feeluown/gui/widgets/table_toolbar.py b/feeluown/gui/widgets/table_toolbar.py index 72a06540c8..ac631ac440 100644 --- a/feeluown/gui/widgets/table_toolbar.py +++ b/feeluown/gui/widgets/table_toolbar.py @@ -1,7 +1,7 @@ from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QHBoxLayout, QComboBox, QWidget -from feeluown.models import AlbumType +from feeluown.library import AlbumType from feeluown.gui.widgets import TextButton diff --git a/feeluown/library/__init__.py b/feeluown/library/__init__.py index 9eb833e4e2..a96caaa51c 100644 --- a/feeluown/library/__init__.py +++ b/feeluown/library/__init__.py @@ -1,6 +1,6 @@ # flake8: noqa from .library import Library -from .provider import AbstractProvider, dummy_provider +from .provider import AbstractProvider from .provider_v2 import ProviderV2 from .flags import Flags as ProviderFlags from .model_state import ModelState @@ -25,3 +25,7 @@ from .excs import NotSupported, NoUserLoggedIn, ModelNotFound, \ ProviderAlreadyExists, ResourceNotFound, MediaNotFound from .provider_protocol import * +from .uri import ( + Resolver, reverse, resolve, ResolverNotFound, ResolveFailed, + parse_line, NS_TYPE_MAP, +) diff --git a/feeluown/library/base.py b/feeluown/library/base.py new file mode 100644 index 0000000000..4087309478 --- /dev/null +++ b/feeluown/library/base.py @@ -0,0 +1,139 @@ +from enum import IntEnum, Enum, IntFlag + + +class ModelFlags(IntFlag): + none = 0x00000000 + + v1 = 0x00000001 + v2 = 0x00000002 + + brief = 0x00000010 + normal = brief | 0x00000020 + + +class ModelType(IntEnum): + dummy = 0 + + song = 1 + artist = 2 + album = 3 + playlist = 4 + lyric = 5 + video = 6 + + user = 17 + comment = 18 + + none = 128 + + +class SearchType(Enum): + pl = 'playlist' + al = 'album' + ar = 'artist' + so = 'song' + vi = 'video' + + @classmethod + def parse(cls, obj): + """get member from object + + :param obj: string or SearchType member + :return: SearchType member + + >>> SearchType.parse('playlist') + + >>> SearchType.parse(SearchType.pl) + + >>> SearchType.parse('xxx') + Traceback (most recent call last): + ... + ValueError: 'xxx' is not a valid SearchType value + """ + if isinstance(obj, SearchType): + return obj + + type_aliases_map = { + cls.pl: ('playlist', 'pl'), + cls.al: ('album', 'al'), + cls.ar: ('artist', 'ar'), + cls.so: ('song', 'so'), + cls.vi: ('video', 'vi'), + } + for type_, aliases in type_aliases_map.items(): + if obj in aliases: + return type_ + raise ValueError("'%s' is not a valid SearchType value" % obj) + + @classmethod + def batch_parse(cls, obj): + """get list of member from obj + + :param obj: obj can be string, list of string or list of member + :return: list of member + + >>> SearchType.batch_parse('pl,ar') + [, ] + >>> SearchType.batch_parse(['pl', 'ar']) + [, ] + >>> SearchType.batch_parse('al') + [] + >>> SearchType.batch_parse(SearchType.al) + [] + >>> SearchType.batch_parse([SearchType.al]) + [] + """ + if isinstance(obj, SearchType): + return [obj] + if isinstance(obj, str): + return [cls.parse(s) for s in obj.split(',')] + return [cls.parse(s) for s in obj] + + +class AlbumType(Enum): + """Album type enumeration + + 中文解释:: + + Single 和 EP 会有一些交集,在展示时,会在一起展示,比如 Singles & EPs。 + Compilation 和 Retrospective 也会有交集,展示时,也通常放在一起,统称“合辑”。 + + References: + + 1. https://www.zhihu.com/question/22888388/answer/33255107 + 2. https://zh.wikipedia.org/wiki/%E5%90%88%E8%BC%AF + """ + standard = 'standard' + + single = 'single' + ep = 'EP' + + live = 'live' + + compilation = 'compilation' + retrospective = 'retrospective' + + @classmethod + def guess_by_name(cls, name): + """guess album type by its name""" + + # album name which contains following string are `Single` + # 1. ' - Single' 6+3=9 + # 2. '(single)' 6+2=8 + # 3. '(single)' 6+2=8 + if 'single' in name[-9:].lower(): + return cls.single + + # ' - EP' + if 'ep' in name[-5:].lower(): + return cls.ep + + if 'live' in name or '演唱会' in name or \ + '音乐会' in name: + return cls.live + + # '精选集', '精选' + if '精选' in name[-3:]: + return cls.retrospective + + return cls.standard diff --git a/feeluown/library/library.py b/feeluown/library/library.py index 370472e353..a23e484143 100644 --- a/feeluown/library/library.py +++ b/feeluown/library/library.py @@ -1,15 +1,14 @@ # mypy: disable-error-code=type-abstract import logging import warnings -from functools import partial, lru_cache +from functools import partial from typing import cast, Optional, Union, TypeVar, Type, Callable, Any from feeluown.media import Media -from feeluown.models import SearchType, ModelType from feeluown.utils import aio from feeluown.utils.dispatch import Signal -from feeluown.utils.utils import log_exectime from feeluown.utils.reader import create_reader +from .base import SearchType, ModelType from .provider import AbstractProvider from .provider_v2 import ProviderV2 from .excs import ( @@ -18,8 +17,7 @@ ) from .flags import Flags as PF from .models import ( - ModelFlags as MF, BaseModel, BriefSongModel, UserModel, - get_modelcls_by_type, + ModelFlags as MF, BriefSongModel, UserModel, ) from .model_protocol import ( BriefVideoProtocol, ModelProtocol, BriefSongProtocol, SongProtocol, UserProtocol, @@ -156,14 +154,6 @@ def register(self, provider): :raises ProviderAlreadyExists: :raises ValueError: - - >>> from feeluown.library import dummy_provider - >>> library = Library(None) - >>> library.register(dummy_provider) - >>> library.register(dummy_provider) - Traceback (most recent call last): - ... - feeluown.excs.ProviderAlreadyRegistered """ if not isinstance(provider, AbstractProvider): raise ValueError('invalid provider instance') @@ -248,69 +238,6 @@ async def a_search(self, keyword, source_in=None, timeout=None, if result is not None: yield result - @log_exectime - def list_song_standby(self, song, onlyone=True): - """try to list all valid standby - - Search a song in all providers. The typical usage scenario is when a - song is not available in one provider, we can try to acquire it from other - providers. - - FIXME: this method will send several network requests, - which may block the caller. - - :param song: song model - :param onlyone: return only one element in result - :return: list of songs (maximum count: 2) - """ - valid_sources = [pvd.identifier for pvd in self.list() - if pvd.identifier != song.source] - q = '{} {}'.format(song.title, song.artists_name) - result_g = self.search(q, source_in=valid_sources) - sorted_standby_list = _extract_and_sort_song_standby_list(song, result_g) - # choose one or two valid standby - result = [] - for standby in sorted_standby_list: - if standby.url: # this may trigger network request - result.append(standby) - if onlyone or len(result) >= 2: - break - return result - - async def a_list_song_standby(self, song, onlyone=True, source_in=None): - """async version of list_song_standby - - .. versionadded:: 3.7.5 - The *source_in* paramter. - """ - if source_in is None: - pvd_ids = self._providers_standby or [pvd.identifier for pvd in self.list()] - else: - pvd_ids = [pvd.identifier for pvd in self._filter(identifier_in=source_in)] - # FIXME(cosven): the model return from netease is new model, - # and it does not has url attribute - valid_providers = [pvd_id for pvd_id in pvd_ids - if pvd_id != song.source and pvd_id != 'netease'] - q = '{} {}'.format(song.title_display, song.artists_name_display) - result_g = [] - async for result in self.a_search(q, source_in=valid_providers): - if result is not None: - result_g.append(result) - sorted_standby_list = _extract_and_sort_song_standby_list(song, result_g) - # choose one or two valid standby - result = [] - for standby in sorted_standby_list: - try: - url = await aio.run_in_executor(None, lambda: standby.url) - except: # noqa - logger.exception('get standby url failed') - else: - if url: - result.append(standby) - if onlyone or len(result) >= 2: - break - return result - async def a_list_song_standby_v2(self, song, audio_select_policy='>>>', source_in=None, score_fn=None, min_score=MIN_SCORE, limit=1): @@ -422,37 +349,6 @@ def check_flags_by_model(self, model: ModelProtocol, flags: PF) -> bool: ModelType(model.meta.model_type), flags) - # --------------------------- - # Methods for backward compat - # --------------------------- - def cast_model_to_v1(self, model): - """Cast a v1/v2 model to v1 (for protocol) - - During the model migration from v1 to v2, v2 may lack some ability. - Cast the model to v1 to acquire such ability. - - :raises NotSupported: provider doesn't support v1 model - """ - if isinstance(model, BaseModel) and (model.meta.flags & MF.v2): - return self._cast_model_to_v1_impl(model) - return model - - @lru_cache(maxsize=1024) - def _cast_model_to_v1_impl(self, model): - provider = self.get_or_raise(model.source) - ModelCls = provider.get_model_cls(model.meta.model_type) - # The source of the default SongModel is None. When ModelCls.source - # is None, it means that the provider does not has its own model class. - if ModelCls.source is None: - model_type_str = repr(ModelType(model.meta.model_type)) - emsg = f'provider:{model.source} has no v1 model for {model_type_str}' - e = NotSupported(emsg, provider=provider) - raise e - kv = {} - for field in ModelCls.meta.fields_display: - kv[field] = getattr(model, field) - return ModelCls.create_by_display(identifier=model.identifier, **kv) - # ----- # Songs # ----- @@ -501,10 +397,6 @@ def song_get_mv(self, song: BriefSongProtocol) -> Optional[VideoProtocol]: provider = self.get_or_raise(song.source) if isinstance(provider, SupportsSongMV): mv = provider.song_get_mv(song) - elif not self.check_flags(song.source, song.meta.model_type, PF.model_v2): - song_v1 = self.cast_model_to_v1(song) - mv = song_v1.mv - mv = cast(Optional[VideoProtocol], mv) else: mv = None return mv @@ -520,12 +412,8 @@ def song_get_lyric(self, song: BriefSongModel) -> Optional[LyricProtocol]: """ provider = self.get_or_raise(song.source) if isinstance(provider, SupportsSongLyric): - lyric = provider.song_get_lyric(song) - else: - song_v1 = self.cast_model_to_v1(song) - lyric = song_v1.lyric - lyric = cast(Optional[LyricProtocol], lyric) - return lyric + return provider.song_get_lyric(song) + raise NotSupported def song_get_web_url(self, song: BriefSongProtocol) -> str: provider = self.getv2_or_raise(song.source) @@ -559,17 +447,7 @@ def _handle_protocol_with_model(self, provider = self.get_or_raise(model.source) if isinstance(provider, protocol_cls): return v2_handler(provider, model) - - try: - v1model = self.cast_model_to_v1(model) - except NotSupported as e: - # Make the error message more informative. - if e.provider is not None: - pid = e.provider.identifier - msg = f'provider:{pid} does not support {protocol_cls.__name__}' - raise NotSupported(msg) - raise # This branch should not be reached. - return v1_handler(v1model) + raise NotSupported(f'{protocol_cls} not supported') # -------- # Artist @@ -640,11 +518,8 @@ def playlist_remove_song(self, playlist, song) -> bool: """ provider = self.get_or_raise(playlist.source) if isinstance(provider, SupportsPlaylistRemoveSong): - ok = provider.playlist_remove_song(playlist, song) - else: - playlist = self.cast_model_to_v1(playlist) - ok = playlist.remove(song.identifier) - return ok + return provider.playlist_remove_song(playlist, song) + raise NotSupported def playlist_add_song(self, playlist, song) -> bool: """Add a song to the playlist @@ -653,11 +528,8 @@ def playlist_add_song(self, playlist, song) -> bool: """ provider = self.get_or_raise(playlist.source) if isinstance(provider, SupportsPlaylistAddSong): - ok = provider.playlist_add_song(playlist, song) - else: - playlist = self.cast_model_to_v1(playlist) - ok = playlist.add(song.identifier) - return ok + return provider.playlist_add_song(playlist, song) + raise NotSupported # ------------------------- # generic methods for model @@ -757,22 +629,7 @@ def _model_upgrade(self, model): model.state = ModelState.not_exists raise ModelNotFound(f'provider:{provider} return an empty model') return upgraded_model - - # Fallback to v1 way if the provider does not support PF.get. - # For example, provider X may support 'get' for SongModel and it - # does not support 'get' for ArtistModel temporarily. It returns - # a SongModel and the song.artists returns list of BriefArtistModel, - # in this condition, BriefArtistModel should be upgraded in v1 way. - return self._model_upgrade_in_v1_way(model) - - def _model_upgrade_in_v1_way(self, model): - v1model = self.cast_model_to_v1(model) - modelcls = get_modelcls_by_type(ModelType(model.meta.model_type)) - fields = [f for f in list(modelcls.__fields__) - if f not in list(BaseModel.__fields__)] - for field in fields: - getattr(v1model, field) - return v1model + raise NotSupported # -------- # Video diff --git a/feeluown/library/models.py b/feeluown/library/models.py index 06b9a7c1f6..fc9aa73f4d 100644 --- a/feeluown/library/models.py +++ b/feeluown/library/models.py @@ -66,9 +66,9 @@ identifier_validator = validator('identifier', pre=True) pydantic_version = 1 -from feeluown.models import ModelType, ModelExistence, ModelStage, ModelFlags, AlbumType -from feeluown.models import SearchType # noqa from feeluown.utils.utils import elfhash +from .base import ModelType, ModelFlags, AlbumType +from .base import SearchType # noqa from .model_state import ModelState @@ -156,9 +156,6 @@ class Config: source: str = 'dummy' state: ModelState = ModelState.artificial - #: (DEPRECATED) for backward compact - exists: ModelExistence = ModelExistence.unknown - @identifier_validator def int_to_str(cls, v): # Old version pydantic convert int to str implicitly. @@ -217,41 +214,6 @@ class BaseBriefModel(BaseModel): """ meta: Any = ModelMeta.create(is_brief=True) - @classmethod - def from_display_model(cls, model): - """Create a new model from an old model in display stage. - - This method never triggers IO operations. - """ - # Due to the display_property mechanism, it is unsafe to - # get attribute of other stage model property. - assert model.stage is ModelStage.display - data = {'state': cls._guess_state_from_exists(model.exists)} - for field in cls.__fields__: - if field in ('state', 'meta'): - continue - if field in ('identifier', 'source', 'exists'): - value = object.__getattribute__(model, field) - else: - if field in model.meta.fields_display: - value = getattr(model, f'{field}_display') - else: - # For example, BriefVideoModel has field `artists_name` and - # the old model does not have such display field. - value = '' - data[field] = value - return cls(**data) - - @classmethod - def _guess_state_from_exists(cls, exists): - if exists == ModelExistence.no: - state_value = ModelState.not_exists - elif exists == ModelExistence.unknown: - state_value = ModelState.artificial - else: - state_value = ModelState.exists - return state_value - class BaseNormalModel(BaseModel): meta: Any = ModelMeta.create(is_normal=False) @@ -307,7 +269,7 @@ class SongModel(BaseNormalModel): """ meta: Any = ModelMeta.create(ModelType.song, is_normal=True) title: str - album: Optional[BriefAlbumModel] = None + album: Optional[TAlbum] = None artists: List[BriefArtistModel] duration: int # milliseconds # A playlist can consist of multiple songs and a song can have many children. diff --git a/feeluown/library/provider.py b/feeluown/library/provider.py index 1af852d8fd..20a29be0ab 100644 --- a/feeluown/library/provider.py +++ b/feeluown/library/provider.py @@ -7,59 +7,15 @@ """ from abc import ABC, abstractmethod from contextlib import contextmanager -from feeluown.models import ( - BaseModel, - SongModel, - ArtistModel, - AlbumModel, - PlaylistModel, - LyricModel, - VideoModel, - - UserModel, - - SearchModel, - - ModelType, -) - - -_TYPE_NAME_MAP = { - ModelType.song: 'Song', - ModelType.artist: 'Artist', - ModelType.album: 'Album', - ModelType.playlist: 'Playlist', - ModelType.lyric: 'Lyric', - ModelType.user: 'User', - ModelType.video: 'Video', -} class AbstractProvider(ABC): """abstract music resource provider """ - # A well behaved provider should implement its own models . - Song = SongModel - Artist = ArtistModel - Album = AlbumModel - Playlist = PlaylistModel - Lyric = LyricModel - User = UserModel - Video = VideoModel - def __init__(self): self._user = None - def get_model_cls(self, model_type): - """Return the modelv1 class""" - name = _TYPE_NAME_MAP[model_type] - return getattr(self, name) - - def set_model_cls(self, model_type, model_cls): - name = _TYPE_NAME_MAP[model_type] - setattr(self, name, model_cls) - @property @abstractmethod def identifier(self): @@ -92,133 +48,3 @@ def auth(self, user): def search(self, *args, **kwargs): pass - - -Dummy = 'dummy' - - -class DummyProvider(AbstractProvider): - """dummy provider, mainly for debug/testing - - People often need a mock/dummy/fake provider/song/album/artist - for debug/testing, so we designed this dummy provider. - - .. note:: - - We MAY add new fields for those models, and we SHOULD not change - the value of existings fields as much as possible. - """ - - @property - def identifier(self): - return Dummy - - @property - def name(self): - return 'Dummy' - - def search(self, *args, **kwargs): - return DummySearchModel( - q=Dummy, - songs=[DummySongModel.get(Dummy)], - artists=[DummyArtistModel.get(Dummy)], - albums=[DummyAlbumModel.get(Dummy)], - playlists=[DummyPlaylistModel.get(Dummy)], - ) - - -dummy_provider = DummyProvider() - - -class DummyBaseModel(BaseModel): - class Meta: - allow_get = True - provider = dummy_provider - - -class DummySongModel(SongModel, DummyBaseModel): - """ - >>> song = dummy_provider.Song.get(Dummy) - >>> song.title - 'dummy' - """ - - @classmethod - def get(cls, identifier): - if identifier == Dummy: - return cls( - identifier=Dummy, - title=Dummy, - duration=0, - artists=[DummyArtistModel.get(Dummy)], - album=DummyAlbumModel.get(Dummy), - url=Dummy, - ) - return None - - -class DummyVideoModel(VideoModel, DummyBaseModel): - @classmethod - def get(cls, identifier): - if identifier == Dummy: - return cls( - identifier=Dummy, - title=Dummy, - media=Dummy, - ) - return None - - -class DummyArtistModel(ArtistModel, DummyBaseModel): - @classmethod - def get(cls, identifier): - if identifier == Dummy: - return cls( - identifier=Dummy, - name=Dummy, - ) - - -class DummyAlbumModel(AlbumModel, DummyBaseModel): - @classmethod - def get(cls, identifier): - if identifier == Dummy: - return cls( - identifier=Dummy, - name=Dummy, - ) - - -class DummyPlaylistModel(PlaylistModel, DummyBaseModel): - @classmethod - def get(cls, identifier): - if identifier == Dummy: - return cls( - identifier=Dummy, - name=Dummy, - ) - - -class DummyLyricModel(LyricModel, DummyBaseModel): - @classmethod - def get(cls, identifier): - if identifier == Dummy: - return cls( - identifier=Dummy, - song=DummySongModel.get(Dummy), - content='', - ) - - -class DummyUserModel(UserModel, DummyBaseModel): - @classmethod - def get(cls, identifier): - if identifier == Dummy: - return cls( - identifier=Dummy, - name=Dummy, - ) - - -class DummySearchModel(SearchModel, DummyBaseModel): - pass diff --git a/feeluown/library/provider_v2.py b/feeluown/library/provider_v2.py index d81b95dff4..f97ddfebd5 100644 --- a/feeluown/library/provider_v2.py +++ b/feeluown/library/provider_v2.py @@ -1,7 +1,7 @@ from typing import Tuple, cast from feeluown.media import Media, Quality -from feeluown.models import ModelType +from .base import ModelType from .models import V2SupportedModelTypes from .flags import Flags from .excs import MediaNotFound, ModelNotFound, NoUserLoggedIn, \ @@ -37,7 +37,7 @@ def use_model_v2(self, model_type: ModelType) -> bool: and IO(network) operations may be performed implicitly. For example, the code `song.url` *may* trigger a network request to fetch the url when `song.url` is currently None. Tips: you can check the - `BaseModel.__getattribute__` implementation in `feeluown.models` package + `BaseModel.__getattribute__` implementation in `feeluown.library` package for more details. For model v2, everything are explicit. Basic attributes of model can be diff --git a/feeluown/models/uri.py b/feeluown/library/uri.py similarity index 88% rename from feeluown/models/uri.py rename to feeluown/library/uri.py index d8f2dd89dd..25a943af1f 100644 --- a/feeluown/models/uri.py +++ b/feeluown/library/uri.py @@ -1,7 +1,7 @@ """ model/uri transform -TODO: move feeluown.server.rpc.model_parser to feeluown.models.parser +TODO: move feeluown.server.rpc.model_parser to feeluown.library.parser .. warn:: @@ -22,7 +22,10 @@ import re import warnings -from .base import ModelType, ModelExistence +from .base import ModelType +from .model_state import ModelState +from .models import get_modelcls_by_type + logger = logging.getLogger(__name__) @@ -213,8 +216,6 @@ def parse_line(line): >>> model.source, model.title_display ('xxx', '没有人知道') """ - from feeluown.library import dummy_provider - line = line.strip() parts = line.split('#', maxsplit=1) if len(parts) == 2: @@ -229,7 +230,7 @@ def parse_line(line): raise ResolveFailed('invalid line: {}'.format(line)) source, ns, identifier = m.groups() path = uri[m.end():] - Model = dummy_provider.get_model_cls(NS_TYPE_MAP[ns]) + Model = get_modelcls_by_type(NS_TYPE_MAP[ns], brief=True) if ns == 'songs': parse_func = parse_song_str elif ns == 'albums': @@ -241,8 +242,8 @@ def parse_line(line): else: parse_func = parse_unknown data = parse_func(model_str.strip()) - model = Model.create_by_display(identifier=identifier, **data) - model.source = source + data['source'] = source + model = Model(identifier=identifier, **data) return model, path @@ -251,29 +252,18 @@ def resolve(line, model=None): for example, line can be 'fuo://local/songs/1/cover/data' """ - from feeluown.library import ( - ProviderFlags, get_modelcls_by_type, V2SupportedModelTypes, - ) + from feeluown.library import get_modelcls_by_type, V2SupportedModelTypes if model is None: model, path = parse_line(line) library = Resolver.library provider = library.get(model.source) if provider is None: - model.exists = ModelExistence.no + model.state = ModelState.not_exists else: - # Try to use model v2 since v1 is deprecated. - if library.check_flags_by_model(model, ProviderFlags.model_v2): - model_type = ModelType(model.meta.model_type) - modelcls = get_modelcls_by_type(model_type, brief=True) - if modelcls is None or \ - model_type not in V2SupportedModelTypes: - assert False, 'library has not support the v2 model for {model_type}' - else: - model = modelcls.from_display_model(model) - else: - model_cls = provider.get_model_cls(model.meta.model_type) - model = model_cls(model) + model_type = ModelType(model.meta.model_type) + modelcls = get_modelcls_by_type(model_type, brief=True) + assert modelcls is not None and model_type in V2SupportedModelTypes else: path = line # NOTE: the path resolve logic is deprecated diff --git a/feeluown/local/db.py b/feeluown/local/db.py index 7ba508b4b6..35550e6786 100644 --- a/feeluown/local/db.py +++ b/feeluown/local/db.py @@ -17,7 +17,7 @@ from feeluown.utils.lang import can_convert_chinese, convert_chinese from feeluown.library import SongModel, AlbumModel, ArtistModel, AlbumType from feeluown.library import BriefAlbumModel, BriefArtistModel, BriefSongModel -from feeluown.models.uri import reverse +from feeluown.library import reverse from .schemas import EasyMP3Model, APEModel, FLACModel from .schemas import DEFAULT_ALBUM_NAME diff --git a/feeluown/models/__init__.py b/feeluown/models/__init__.py deleted file mode 100644 index 66dd705dd6..0000000000 --- a/feeluown/models/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- - -from feeluown.utils.reader import SequentialReader as GeneratorProxy # noqa, for backward compatible -from .base import ( - cached_field, ModelType, Model, ModelExistence, ModelStage, - SearchType, AlbumType, display_property, ModelFlags, -) -from .models import ( - BaseModel, LyricModel, - SongModel, AlbumModel, ArtistModel, PlaylistModel, - UserModel, MvModel, SearchModel, VideoModel, -) -from .uri import ( - resolve, - reverse, - Resolver, - ResolveFailed, - ResolverNotFound, -) # noqa - -__all__ = ( - 'resolve', - 'reverse', - 'ResolveFailed', - 'ResolverNotFound', # TODO: should not expose Resolver conceptt - 'Resolver', - 'cached_field', - - # base - 'display_property', - 'cached_field', - 'ModelType', - 'Model', - 'ModelExistence', - 'ModelStage', - 'ModelFlags', - - 'AlbumType', - 'SearchType', - - # models - 'BaseModel', - 'SongModel', - 'AlbumModel', - 'ArtistModel', - 'PlaylistModel', - 'UserModel', - 'SearchModel', - 'LyricModel', - 'MvModel', - 'VideoModel', -) diff --git a/feeluown/models/base.py b/feeluown/models/base.py deleted file mode 100644 index b7bd04fb6a..0000000000 --- a/feeluown/models/base.py +++ /dev/null @@ -1,397 +0,0 @@ -import logging -from enum import IntEnum, Enum, IntFlag - -from feeluown.utils.cache import cached_field # noqa, for backward compatibility - - -logger = logging.getLogger(__name__) - - -class ModelType(IntEnum): - dummy = 0 - - song = 1 - artist = 2 - album = 3 - playlist = 4 - lyric = 5 - video = 6 - - user = 17 - comment = 18 - - none = 128 - - -class SearchType(Enum): - pl = 'playlist' - al = 'album' - ar = 'artist' - so = 'song' - vi = 'video' - - @classmethod - def parse(cls, obj): - """get member from object - - :param obj: string or SearchType member - :return: SearchType member - - >>> SearchType.parse('playlist') - - >>> SearchType.parse(SearchType.pl) - - >>> SearchType.parse('xxx') - Traceback (most recent call last): - ... - ValueError: 'xxx' is not a valid SearchType value - """ - if isinstance(obj, SearchType): - return obj - - type_aliases_map = { - cls.pl: ('playlist', 'pl'), - cls.al: ('album', 'al'), - cls.ar: ('artist', 'ar'), - cls.so: ('song', 'so'), - cls.vi: ('video', 'vi'), - } - for type_, aliases in type_aliases_map.items(): - if obj in aliases: - return type_ - raise ValueError("'%s' is not a valid SearchType value" % obj) - - @classmethod - def batch_parse(cls, obj): - """get list of member from obj - - :param obj: obj can be string, list of string or list of member - :return: list of member - - >>> SearchType.batch_parse('pl,ar') - [, ] - >>> SearchType.batch_parse(['pl', 'ar']) - [, ] - >>> SearchType.batch_parse('al') - [] - >>> SearchType.batch_parse(SearchType.al) - [] - >>> SearchType.batch_parse([SearchType.al]) - [] - """ - if isinstance(obj, SearchType): - return [obj] - if isinstance(obj, str): - return [cls.parse(s) for s in obj.split(',')] - return [cls.parse(s) for s in obj] - - -class ModelStage(IntEnum): - """Model 所处的阶段,有大小关系 - - 通过 create_by_display 工厂函数创建的实例,实例所处阶段为 display, - 通过构造函数创建的实例,阶段为 inited, 如果 model 已经 get 过, - 则阶段为 gotten. - - 目前,主要是 __getattribute__ 方法需要读取 model 所处的阶段, - 避免重复 get model。 - """ - display = 4 - inited = 8 - gotten = 16 - - -class ModelExistence(IntEnum): - """资源是否真的存在 - - 在许多音乐平台,当一个歌手、专辑不存在时,它们的接口可能构造一个 - id 为 0, name 为 None 的字典。这类 model.exists 应该被置为 no。 - - 这个字段不应该被缓存。 - """ - no = -1 - unknown = 0 - yes = 1 - - -class ModelMetadata(object): - def __init__(self, - model_type=ModelType.dummy.value, - provider=None, - fields=None, - fields_display=None, - fields_no_get=None, - paths=None, - allow_get=False, - allow_batch=False, - **kwargs): - """Model metadata class - - :param allow_get: if get method is implemented - :param allow_batch: if list method is implemented - """ - self.model_type = model_type - self.provider = provider - self.fields = fields or [] - self.fields_display = fields_display or [] - self.fields_no_get = fields_no_get or [] - self.paths = paths or [] - self.allow_get = allow_get - self.allow_batch = allow_batch - for key, value in kwargs.items(): - setattr(self, key, value) - - -class display_property: - """Model 的展示字段的描述器""" - - def __init__(self, name): - #: display 属性对应的真正属性的名字 - self.name_real = name - #: 用来存储值的属性名 - self.store_pname = '_display_store_' + name - - def __get__(self, instance, _=None): - if instance is None: - return self - if instance.stage >= ModelStage.inited: - return getattr(instance, self.name_real) - return getattr(instance, self.store_pname, '') - - def __set__(self, instance, value): - setattr(instance, self.store_pname, value) - - -class ModelMeta(type): - def __new__(cls, name, bases, attrs): - # 获取 Model 当前以及父类中的 Meta 信息 - # 如果 Meta 中相同字段的属性,子类的值可以覆盖父类的 - _metas = [] - for base in bases: - base_meta = getattr(base, '_meta', None) - if base_meta is not None: - _metas.append(base_meta) - Meta = attrs.pop('Meta', None) - if Meta: - _metas.append(Meta) - - kind_fields_map = {'fields': [], - 'fields_display': [], - 'fields_no_get': [], - 'paths': []} - meta_kv = {} # 实例化 ModelMetadata 的 kv 对 - for _meta in _metas: - for kind, fields in kind_fields_map.items(): - fields.extend(getattr(_meta, kind, [])) - for k, v in _meta.__dict__.items(): - if k.startswith('_') or k in kind_fields_map: - continue - if k == 'model_type': - if ModelType(v) != ModelType.dummy: - meta_kv[k] = v - else: - meta_kv[k] = v - - klass = type.__new__(cls, name, bases, attrs) - - # update provider - provider = meta_kv.pop('provider', None) - model_type = meta_kv.pop('model_type', ModelType.dummy.value) - if provider and ModelType(model_type) != ModelType.dummy: - provider.set_model_cls(model_type, klass) - - fields_all = list(set(kind_fields_map['fields'])) - fields_display = list(set(kind_fields_map['fields_display'])) - fields_no_get = list(set(kind_fields_map['fields_no_get'])) - paths = list(set(kind_fields_map['paths'])) - - for field in fields_display: - setattr(klass, field + '_display', display_property(field)) - - # DEPRECATED attribute _meta - # TODO: remove this in verion 2.3 - klass._meta = ModelMetadata(model_type=model_type, - provider=provider, - fields=fields_all, - fields_display=fields_display, - fields_no_get=fields_no_get, - paths=paths, - **meta_kv) - # FIXME: theoretically, different provider can share same model, - # so source field should be a instance attribute instead of class attribute. - # however, we don't have enough time to fix this whole design. - klass.source = provider.identifier if provider is not None else None - # use meta attribute instead of _meta - klass.meta = klass._meta - return klass - - -class AlbumType(Enum): - """Album type enumeration - - 中文解释:: - - Single 和 EP 会有一些交集,在展示时,会在一起展示,比如 Singles & EPs。 - Compilation 和 Retrospective 也会有交集,展示时,也通常放在一起,统称“合辑”。 - - References: - - 1. https://www.zhihu.com/question/22888388/answer/33255107 - 2. https://zh.wikipedia.org/wiki/%E5%90%88%E8%BC%AF - """ - standard = 'standard' - - single = 'single' - ep = 'EP' - - live = 'live' - - compilation = 'compilation' - retrospective = 'retrospective' - - @classmethod - def guess_by_name(cls, name): - """guess album type by its name""" - - # album name which contains following string are `Single` - # 1. ' - Single' 6+3=9 - # 2. '(single)' 6+2=8 - # 3. '(single)' 6+2=8 - if 'single' in name[-9:].lower(): - return cls.single - - # ' - EP' - if 'ep' in name[-5:].lower(): - return cls.ep - - if 'live' in name or '演唱会' in name or \ - '音乐会' in name: - return cls.live - - # '精选集', '精选' - if '精选' in name[-3:]: - return cls.retrospective - - return cls.standard - - -class Model(metaclass=ModelMeta): - """base class for data models - - Usage:: - - class User(Model): - class Meta: - fields = ['name', 'title'] - - user = UserModel(name='xxx') - assert user.name == 'xxx' - user2 = UserModel(user) - assert user2.name == 'xxx' - """ - - def __init__(self, obj=None, **kwargs): - # ensure all field are initialized to None - for field in self.meta.fields: - setattr(self, field, None) - - # copy fields from obj as many as possible - if obj is not None: - for field in obj.meta.fields: - value = object.__getattribute__(obj, field) - setattr(self, field, value) - for field in obj.meta.fields_display: - field_name = f'{field}_display' - field_display_prop = getattr(type(self), field_name) - field_display_prop.__set__(self, getattr(obj, field_name)) - # source should be a instance attribute although it is not temporarily - self.source = obj.source - self.stage = obj.stage - self.exists = obj.exists - else: - for field in self.meta.fields: - setattr(self, field, None) - #: model 所处阶段。目前,通过构造函数初始化的 model - # 所处阶段为 inited,通过 get 得到的 model,所处阶段为 gotten, - # 通过 display 属性构造的 model,所处阶段为 display。 - # 目前,此属性仅为 models 模块使用,不推荐外部依赖。 - self.stage = kwargs.get('stage', ModelStage.inited) - #: 歌曲是否存在。如果 Model allow_get,但 get 却不能获取到 model, - # 则该 model 不存在。 - self.exists = kwargs.get('stage', ModelExistence.unknown) - - for k, v in kwargs.items(): - if k in self.meta.fields: - setattr(self, k, v) - - def __getattribute__(self, name): - """ - 获取 model 某一属性时,如果该属性值为 None 且该属性是 field - 且该属性允许触发 get 方法,这时,我们尝试通过获取 model - 详情来初始化这个字段,于此同时,还会重新给部分 fields 重新赋值。 - """ - cls = type(self) - cls_name = cls.__name__ - value = object.__getattribute__(self, name) - - if name in ('identifier', 'meta', '_meta', 'stage', 'exists'): - return value - - if name in cls.meta.fields \ - and name not in cls.meta.fields_no_get \ - and value is None \ - and cls.meta.allow_get \ - and self.stage < ModelStage.gotten \ - and self.exists != ModelExistence.no: - - # debug snippet: show info of the caller that trigger the model.get call - # - # import inspect - # frame = inspect.currentframe() - # caller = frame.f_back - # logger.info( - # '%s %d %s', - # caller.f_code.co_filename, caller.f_lineno, caller.f_code.co_name - # ) - - logger.debug("Model {} {}'s value is None, try to get detail." - .format(repr(self), name)) - obj = cls.get(self.identifier) - if obj is not None: - for field in cls.meta.fields: - # 类似 @property/@cached_field 等字段,都应该加入到 - # fields_no_get 列表中 - if field in cls.meta.fields_no_get: - continue - # 这里不能使用 getattr,否则有可能会无限 get - fv = object.__getattribute__(obj, field) - if fv is not None: - setattr(self, field, fv) - self.stage = ModelStage.gotten - self.exists = ModelExistence.yes - else: - self.exists = ModelExistence.no - logger.warning('Model {} get return None'.format(cls_name)) - value = object.__getattribute__(self, name) - return value - - @classmethod - def create_by_display(cls, identifier, **kwargs): - """create model instance with identifier and display fields""" - model = cls(identifier=identifier) - model.stage = ModelStage.display - model.exists = ModelExistence.unknown - for k, v in kwargs.items(): - if k in cls.meta.fields_display: - setattr(model, k + '_display', v) - return model - - -class ModelFlags(IntFlag): - none = 0x00000000 - - v1 = 0x00000001 - v2 = 0x00000002 - - brief = 0x00000010 - normal = brief | 0x00000020 diff --git a/feeluown/models/models.py b/feeluown/models/models.py deleted file mode 100644 index e637688e12..0000000000 --- a/feeluown/models/models.py +++ /dev/null @@ -1,384 +0,0 @@ -# type: ignore - -import time -import logging -import warnings - -from feeluown.media import MultiQualityMixin, Quality -from feeluown.utils.utils import elfhash -from .base import ModelType, AlbumType, Model, ModelFlags - -logger = logging.getLogger(__name__) - - -def _get_artists_name(artists): - # [a, b, c] -> 'a, b & c' - artists_name = ', '.join((artist.name for artist in artists)) - return ' & '.join(artists_name.rsplit(', ', 1)) - - -class BaseModel(Model): - """Base model for music resource""" - - class Meta: - """Model metadata""" - - flags = ModelFlags.none # flags should help us upgrade to model v2 gracefully - allow_get = True #: whether model has a valid get method - allow_list = False #: whether model has a valid list method - model_type = ModelType.dummy.value - - #: declare model fields, each model must have an identifier field - fields = ['identifier', '_cache'] - - #: Model 用来展示的字段 - fields_display = [] - - #: 不触发 get 的 Model 字段,这些字段往往 get 是获取不到的 - fields_no_get = ['identifier', '_cache'] - - def __eq__(self, other): - if not isinstance(other, BaseModel): - return False - return all([other.source == self.source, - str(other.identifier) == str(self.identifier), - other.meta.model_type == self.meta.model_type]) - - @classmethod - def get(cls, identifier): - """get model instance by identifier""" - - @classmethod - def list(cls, identifier_list): - """Model batch get method""" - - def cache_get(self, key): - self._init_cache() - if key in self._cache: - value, expired_at = self._cache[key] - if expired_at is None or expired_at >= int(time.time()): - return value, True - return None, False - - def cache_set(self, key, value, ttl=None): - """ - :param int ttl: the unit is seconds. - """ - self._init_cache() - if ttl is None: - expired_at = None - else: - expired_at = int(time.time()) + ttl - self._cache[key] = (value, expired_at) - - def _init_cache(self): - # not thread safe - if self._cache is None: - self._cache = {} - - -class ArtistModel(BaseModel): - """Artist Model""" - - class Meta: - model_type = ModelType.artist.value - fields = ['name', 'cover', 'songs', 'desc', 'albums'] - fields_display = ['name'] - allow_create_songs_g = False - allow_create_albums_g = False - - def __str__(self): - return 'fuo://{}/artists/{}'.format(self.source, self.identifier) - - def create_songs_g(self): - """create songs generator(alpha)""" - pass - - def create_albums_g(self): - pass - - @property - def aliases(self): - return [] - - @property - def hot_songs(self): # To be compatible with ArtistModel v2. - return self.songs - - @property - def pic_url(self): # To be compatible with ArtistModel v2. - return self.cover - - @property - def description(self): # To be compatible with ArtistModel v2. - return self.desc - - @property - def children(self): - return [] - - def __getattribute__(self, name): - value = super().__getattribute__(name) - if name == 'songs': - warnings.warn('please use/implement .create_songs_g') - return value - - -class AlbumModel(BaseModel): - class Meta: - model_type = ModelType.album.value - - # TODO: 之后可能需要给 Album 多加一个字段用来分开表示 artist 和 singer - # 从意思上来区分的话:artist 是专辑制作人,singer 是演唱者 - # 像虾米音乐中,它即提供了专辑制作人信息,也提供了 singer 信息 - fields = ['name', 'cover', 'songs', 'artists', 'desc', 'type'] - fields_display = ['name', 'artists_name'] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if kwargs.get('type') is None: - name = kwargs.get('name') - if name: - self.type = AlbumType.guess_by_name(name) - else: - self.type = AlbumType.standard - - def __str__(self): - return 'fuo://{}/albums/{}'.format(self.source, self.identifier) - - @property - def artists_name(self): - return _get_artists_name(self.artists or []) - - @property - def description(self): # To be compatible with AlbumModel v2. - return self.desc - - @property - def type_(self): # To be compatible with AlbumModel v2. - return self.type - - @property - def released(self): # To be compatible with AlbumModel v2. - return '' - - -class LyricModel(BaseModel): - """Lyric Model - - :param SongModel song: song which lyric belongs to - :param str content: lyric content - :param str trans_content: translated lyric content - """ - class Meta: - model_type = ModelType.lyric.value - fields = ['song', 'content', 'trans_content'] - - -class MvModel(BaseModel, MultiQualityMixin): - QualityCls = Quality.Video - - class Meta: - model_type = ModelType.video.value - fields = ['name', 'media', 'desc', 'cover', 'artists', 'duration'] - support_multi_quality = False - fields_display = ['name'] - - @property - def title(self): - """ - V2 VideoModel use `title` instead of `name`. - """ - return self.name - - @title.setter - def title(self, value): - self.name = value - - @property - def title_display(self): - """ - To be compatible with VideoModel v2. - """ - return self.name_display - - -class SongModel(BaseModel, MultiQualityMixin): - QualityCls = Quality.Audio - - class Meta: - model_type = ModelType.song.value - fields = ['album', 'artists', 'lyric', 'comments', 'title', 'url', - 'duration', 'mv', 'media', - 'disc', 'genre', 'date', 'track', 'pic_url', 'children'] - fields_display = ['title', 'artists_name', 'album_name', 'duration_ms'] - - support_multi_quality = False - - @property - def artists_name(self): - return _get_artists_name(self.artists or []) - - @property - def album_name(self): - return self.album.name if self.album is not None else '' - - @property - def duration_ms(self): - if self.duration is not None: - seconds = self.duration / 1000 - m, s = seconds / 60, seconds % 60 - else: - m, s = 0, 0 - return '{:02}:{:02}'.format(int(m), int(s)) - - @property - def filename(self): - return '{} - {}.mp3'.format(self.title, self.artists_name) - - def __str__(self): - return 'fuo://{}/songs/{}'.format(self.source, self.identifier) # noqa - - def __hash__(self): - try: - id_hash = int(self.identifier) - except ValueError: - id_hash = elfhash(self.identifier.encode()) - return id_hash * 1000 + id(type(self)) % 1000 - - def __eq__(self, other): - if not isinstance(other, SongModel): - return False - return all([other.source == self.source, - str(other.identifier) == str(self.identifier)]) - - -class PlaylistModel(BaseModel): - class Meta: - model_type = ModelType.playlist.value - fields = ['name', 'cover', 'songs', 'desc'] - fields_display = ['name'] - allow_create_songs_g = False - - def __str__(self): - return 'fuo://{}/playlists/{}'.format(self.source, self.identifier) - - @property - def creator(self): # To be compatible with PlaylistModel v2. - return None - - @property - def creator_name(self): # To be compatible with PlaylistModel v2. - return '' - - def __getattribute__(self, name): - value = super().__getattribute__(name) - if name == 'songs': - warnings.warn('please use/implement .create_songs_g') - return value - - def add(self, song_id): - """add song to playlist, return true if succeed. - - If the song was in playlist already, return true. - """ - pass - - def remove(self, song_id): - """remove songs from playlist, return true if succeed - - If song is not in playlist, return true. - """ - pass - - def create_songs_g(self): - pass - - @property - def description(self): # To be compatible with ArtistModel v2. - return self.desc - - -class SearchModel(BaseModel): - """Search Model - - TODO: support album and artist - """ - class Meta: - model_type = ModelType.dummy.value - - # XXX: songs should be a empty list instead of None - # when there is not song. - fields = ['q', 'songs', 'playlists', 'artists', 'albums', 'videos'] - fields_no_get = ['q', 'songs', 'playlists', 'artists', 'albums', 'videos'] - - def __str__(self): - return 'fuo://{}?q={}'.format(self.source, self.q) - - -class UserModel(BaseModel): - """User Model - - :param name: user name - :param playlists: playlists created by user - :param fav_playlists: playlists collected by user - :param fav_songs: songs collected by user - :param fav_albums: albums collected by user - :param fav_artists: artists collected by user - """ - class Meta: - allow_fav_songs_add = False - allow_fav_songs_remove = False - allow_fav_playlists_add = False - allow_fav_playlists_remove = False - allow_fav_albums_add = False - allow_fav_albums_remove = False - allow_fav_artists_add = False - allow_fav_artists_remove = False - - model_type = ModelType.user.value - fields = ['name', 'avatar_url', 'playlists', 'fav_playlists', 'fav_songs', - 'fav_albums', 'fav_artists', 'rec_songs', 'rec_playlists'] - fields_display = ['name'] - - def add_to_fav_songs(self, song_id): - """add song to favorite songs, return True if success - - :param song_id: song identifier - :return: Ture if success else False - :rtype: boolean - """ - pass - - def remove_from_fav_songs(self, song_id): - pass - - def add_to_fav_playlists(self, playlist_id): - pass - - def remove_from_fav_playlists(self, playlist_id): - pass - - def add_to_fav_albums(self, album_id): - pass - - def remove_from_fav_albums(self, album_id): - pass - - def add_to_fav_artists(self, aritst_id): - pass - - def remove_from_fav_artists(self, artist_id): - pass - - -class VideoModel(BaseModel): - - class Meta: - model_type = ModelType.video.value - fields = ['title', 'cover', 'media'] - fields_display = ['title'] - - def __str__(self): - return f'fuo://{self.source}/videos/{self.identifier}' diff --git a/feeluown/player/playlist.py b/feeluown/player/playlist.py index 51238899e3..64b96e7c81 100644 --- a/feeluown/player/playlist.py +++ b/feeluown/player/playlist.py @@ -15,7 +15,7 @@ MediaNotFound, SongProtocol, ModelType, NotSupported, ResourceNotFound ) from feeluown.media import Media -from feeluown.models.uri import reverse +from feeluown.library import reverse if TYPE_CHECKING: from feeluown.app import App @@ -81,7 +81,7 @@ class Playlist: def __init__(self, app: 'App', songs=None, playback_mode=PlaybackMode.loop, audio_select_policy='hq<>'): """ - :param songs: list of :class:`feeluown.models.SongModel` + :param songs: list of :class:`feeluown.library.SongModel` :param playback_mode: :class:`feeluown.player.PlaybackMode` """ self._app = app diff --git a/feeluown/serializers/model_helpers.py b/feeluown/serializers/model_helpers.py index 20b772677a..244d0a70e0 100644 --- a/feeluown/serializers/model_helpers.py +++ b/feeluown/serializers/model_helpers.py @@ -14,16 +14,7 @@ BriefPlaylistModel, BriefUserModel, ) -from feeluown.models import ( - SongModel as SongModelV1, - ArtistModel as ArtistModelV1, - AlbumModel as AlbumModelV1, - PlaylistModel as PlaylistModelV1, - UserModel as UserModelV1, - SearchModel as SearchModelV1, - - reverse, -) +from feeluown.library import reverse class ModelSerializerMixin: @@ -61,7 +52,7 @@ def _get_items(self, model): class SongSerializerMixin: class Meta: - types = (SongModel, SongModelV1, BriefSongModel) + types = (SongModel, BriefSongModel) # since url can be too long, we put it at last fields = ('title', 'duration', 'album', 'artists') line_fmt = '{uri:{uri_length}}\t# {title:_18} - {artists_name:_20}' @@ -69,28 +60,28 @@ class Meta: class ArtistSerializerMixin: class Meta: - types = (ArtistModel, ArtistModelV1, BriefArtistModel) + types = (ArtistModel, BriefArtistModel) fields = ('name', 'songs') line_fmt = '{uri:{uri_length}}\t# {name:_40}' class AlbumSerializerMixin: class Meta: - types = (AlbumModel, AlbumModelV1, BriefAlbumModel) + types = (AlbumModel, BriefAlbumModel) fields = ('name', 'artists', 'songs') line_fmt = '{uri:{uri_length}}\t# {name:_18} - {artists_name:_20}' class PlaylistSerializerMixin: class Meta: - types = (PlaylistModel, PlaylistModelV1, BriefPlaylistModel) + types = (PlaylistModel, BriefPlaylistModel) fields = ('name', ) line_fmt = '{uri:{uri_length}}\t# {name:_40}' class UserSerializerMixin: class Meta: - types = (UserModel, UserModelV1, BriefUserModel) + types = (UserModel, BriefUserModel) fields = ('name', 'playlists') line_fmt = '{uri:{uri_length}}\t# {name:_40}' @@ -106,7 +97,7 @@ class SearchSerializerMixin: """ class Meta: - types = (SearchModelV1, ) + types = () def _get_items(self, result): fields = ('songs', 'albums', 'artists', 'playlists',) diff --git a/feeluown/serializers/plain.py b/feeluown/serializers/plain.py index c9712e2221..f6ecebea5c 100644 --- a/feeluown/serializers/plain.py +++ b/feeluown/serializers/plain.py @@ -1,6 +1,6 @@ from textwrap import indent # FIXME: maybe we should move `reverse` into serializers package -from feeluown.models.uri import reverse +from feeluown.library import reverse from .base import Serializer, SerializerMeta, SerializerError from .model_helpers import ModelSerializerMixin, SongSerializerMixin, \ ArtistSerializerMixin, AlbumSerializerMixin, PlaylistSerializerMixin, \ diff --git a/feeluown/server/handlers/player.py b/feeluown/server/handlers/player.py index 7cad8e38fe..79f62bf9b6 100644 --- a/feeluown/server/handlers/player.py +++ b/feeluown/server/handlers/player.py @@ -2,7 +2,7 @@ from difflib import SequenceMatcher from typing import Any -from feeluown.models.uri import resolve, reverse +from feeluown.library import resolve, reverse from .base import AbstractHandler diff --git a/feeluown/server/handlers/playlist.py b/feeluown/server/handlers/playlist.py index 1f6ede7508..b19cf96e76 100644 --- a/feeluown/server/handlers/playlist.py +++ b/feeluown/server/handlers/playlist.py @@ -1,5 +1,5 @@ -from feeluown.models import ModelType -from feeluown.models.uri import resolve, reverse +from feeluown.library import ModelType +from feeluown.library import resolve, reverse from feeluown.utils.utils import to_readall_reader from .base import AbstractHandler diff --git a/feeluown/server/handlers/search.py b/feeluown/server/handlers/search.py index 6809876ecb..0448f4233d 100644 --- a/feeluown/server/handlers/search.py +++ b/feeluown/server/handlers/search.py @@ -1,4 +1,6 @@ import logging + +from feeluown.library import SupportsSongGet from .base import AbstractHandler logger = logging.getLogger(__name__) @@ -31,7 +33,7 @@ def search(self, keyword, options=None): """ providers = self.library.list() source_in = [provd.identifier for provd in providers - if provd.Song.meta.allow_get] + if isinstance(provd, SupportsSongGet)] params = {} if options is not None: type_in = options.pop('type', None) diff --git a/feeluown/server/handlers/show.py b/feeluown/server/handlers/show.py index 401eb8c171..3c51d52395 100644 --- a/feeluown/server/handlers/show.py +++ b/feeluown/server/handlers/show.py @@ -15,8 +15,8 @@ from feeluown.utils.utils import to_readall_reader from feeluown.utils.router import Router, NotFound from feeluown.library import NotSupported, ResourceNotFound -from feeluown.models.uri import NS_TYPE_MAP -from feeluown.models import ModelType +from feeluown.library import NS_TYPE_MAP +from feeluown.library import ModelType from .base import AbstractHandler from .excs import HandlerException diff --git a/integration-tests/run.py b/integration-tests/run.py index f21d091edf..93e7f543a2 100755 --- a/integration-tests/run.py +++ b/integration-tests/run.py @@ -37,15 +37,6 @@ def wait_until_23333_service_ok(timeout): return True return False -def register_dummy_provider(): - req = Request('exec', has_heredoc=True, heredoc_word='EOF') - req.set_heredoc_body(''' -from feeluown.library.provider import dummy_provider -app.library.register(dummy_provider) -''') - with create_client() as client: - asyncio.run(client.send(req)) - def collect(): for key in globals(): @@ -58,12 +49,7 @@ def test_show_providers_with_json_format(): with create_client() as client: resp = asyncio.run( client.send(Request('show', ['fuo://'], options={'format': 'json'}))) - providers = json.loads(resp.text) - for provider in providers: - if provider['identifier'] == 'dummy': - break - else: - assert False, 'dummy provider should be found' + json.loads(resp.text) def test_cmd_options(): @@ -88,7 +74,6 @@ def run(): popen = subprocess.Popen(['fuo', '-v']) assert wait_until_23333_service_ok(timeout=10) - register_dummy_provider() failed = False for case in collect(): diff --git a/tests/conftest.py b/tests/conftest.py index 77ab9a7713..79d08196d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,10 @@ from unittest.mock import MagicMock import pytest -from feeluown import models from feeluown.library import ( AbstractProvider, ProviderV2, ModelType, ProviderFlags as PF, AlbumModel, ArtistModel, BriefVideoModel, BriefSongModel, - Library, SongModel, BriefAlbumModel, BriefArtistModel + Library, SongModel, BriefAlbumModel, BriefArtistModel, SimpleSearchResult ) from feeluown.media import Quality, Media, MediaType from feeluown.utils.reader import create_reader @@ -26,7 +25,7 @@ def name(self): return 'FAKE' def search(self, keyword, **kwargs): - return FakeSearchModel(q=keyword, songs=[_song1, _song2, _song3]) + return SimpleSearchResult(q=keyword, songs=[_song1, _song2, _song3]) class EkafProvider(AbstractProvider, ProviderV2): @@ -87,31 +86,17 @@ def video_get_media(self, video, quality): _fake_provider = FakeProvider() +_song1 = SongModel(source=FakeSource, + identifier='1', title='1', album=None, + artists=[], duration=0) +_song2 = SongModel(source=FakeSource, + identifier='2', title='2', album=None, + artists=[], duration=0) +_song3 = SongModel(source=FakeSource, + identifier='3', title='3', album=None, + artists=[], duration=0) -class FakeSongModel(models.SongModel): - class Meta: - provider = _fake_provider - - -class FakeArtistModel(models.ArtistModel): - class Meta: - provider = _fake_provider - - -class FakeAlbumModel(models.AlbumModel): - class Meta: - provider = _fake_provider - - -class FakeSearchModel(models.SearchModel): - class Meta: - provider = _fake_provider - - -_song1 = FakeSongModel(identifier=1, url='1.mp3') -_song2 = FakeSongModel(identifier=2, url='2.mp3') -_song3 = FakeSongModel(identifier=3) _ekaf_brief_song0 = BriefSongModel(source=EkafSource, identifier='0') _ekaf_brief_album0 = BriefAlbumModel(source=EkafSource, @@ -131,16 +116,6 @@ class Meta: artists=[_ekaf_brief_artist0], duration=0) -@pytest.fixture -def artist(): - return FakeArtistModel(identifier=0, name='mary') - - -@pytest.fixture -def album(): - return FakeAlbumModel(identifier=0, name='blue and green') - - @pytest.fixture def ekaf_brief_song0(): return _ekaf_brief_song0 @@ -162,26 +137,34 @@ def ekaf_artist0(): @pytest.fixture -def song(artist, album): - return FakeSongModel( +def artist(): + return BriefArtistModel(identifier=0, source='fake', name='mary') + + +@pytest.fixture +def album(artist): + return AlbumModel( identifier=0, + source='fake', + name='blue and green', + artists=[], + songs=[], + cover='', + description='', + ) + + +@pytest.fixture +def song(artist, album): + return SongModel( + identifier='0', + source=FakeSource, title='hello world', artists=[artist], album=album, duration=600000, - url='http://xxx.com/xxx.mp3') - - -@pytest.fixture -def song_standby(song): - return FakeSongModel( - identifier=100, - title=song.title, - artists=song.artists, - album=song.album, - duration=song.duration, - url='standby.mp3' - ) + ) + # url='http://xxx.com/xxx.mp3' @pytest.fixture diff --git a/tests/gui/test_helpers.py b/tests/gui/test_helpers.py index 27e332c6a3..6527a4dde8 100644 --- a/tests/gui/test_helpers.py +++ b/tests/gui/test_helpers.py @@ -2,7 +2,7 @@ import pytest -from feeluown.models import reverse +from feeluown.library import reverse from feeluown.library import ModelNotFound from feeluown.gui.helpers import fetch_cover_wrapper diff --git a/tests/library/test_library.py b/tests/library/test_library.py index 42d851222a..09fcd64e96 100644 --- a/tests/library/test_library.py +++ b/tests/library/test_library.py @@ -2,15 +2,7 @@ import pytest -from feeluown.library import Library, ModelType, BriefAlbumModel -from feeluown.library.provider import dummy_provider -from feeluown.models import SearchModel - - -def test_library_search(library): - result = list(library.search('xxx'))[0] - assert len(result.songs) >= 3 - assert result.songs[0].identifier == 1 +from feeluown.library import ModelType, BriefAlbumModel @pytest.mark.asyncio @@ -65,60 +57,6 @@ def create_song(title, artists_name, album_name, duration_ms): assert default_score_fn(song, candidates[0]) >= MIN_SCORE -def test_library_list_songs_standby(library, song): - songs = library.list_song_standby(song) - - # all songs share the same provider, - # so there will be no standby song - assert len(songs) == 0 - - song.source = 'dummy-1' - songs = library.list_song_standby(song) - assert len(songs) == 1 - - songs = library.list_song_standby(song, onlyone=False) - assert len(songs) == 2 - - -@pytest.mark.asyncio -async def test_library_a_list_songs_standby(library, song): - songs = await library.a_list_song_standby(song) - assert len(songs) <= 1 - - song.source = 'dummy-1' - songs = await library.a_list_song_standby(song) - assert len(songs) == 1 - - -@pytest.mark.asyncio -async def test_library_a_list_songs_standby_with_specified_providers(song): - library = Library(providers_standby=['xxx']) - song.source = 'dummy-1' - songs = await library.a_list_song_standby(song) - assert len(songs) == 0 - - -@pytest.mark.asyncio -async def test_library_a_list_songs_standby_v2(library, provider, - song, song1, song_standby, mocker): - mock_search = mocker.patch.object(provider, 'search') - mock_search.return_value = SearchModel(q='xx', songs=[song1, song_standby]) - - song_media_list = await library.a_list_song_standby_v2(song) - for standby, media in song_media_list: - if standby is song_standby: - assert media.url == 'standby.mp3' - break - else: - assert False, 'song_standby should be a stanby option' - - -def test_library_register_should_emit_signal(library, mocker): - mock_emit = mocker.patch('feeluown.utils.dispatch.Signal.emit') - library.register(dummy_provider) - mock_emit.assert_called_once_with(dummy_provider) - - def test_library_model_get(library, ekaf_provider, ekaf_album0): album = library.model_get(ekaf_provider.identifier, ModelType.album, diff --git a/tests/library/test_protocol.py b/tests/library/test_protocol.py index 17acbd33a2..6af8d7e6d9 100644 --- a/tests/library/test_protocol.py +++ b/tests/library/test_protocol.py @@ -12,12 +12,6 @@ BriefUserProtocol, UserProtocol, VideoProtocol, ) -from feeluown.models import ( - AlbumModel as AlbumModelV1, - ArtistModel as ArtistModelV1, - SongModel as SongModelV1, - UserModel as UserModelV1, -) def test_protocols(): @@ -40,35 +34,24 @@ def test_protocols(): duration=0, **values) - album_v1 = AlbumModelV1(**values) - artist_v1 = ArtistModelV1(**values) - song_v1 = SongModelV1(**values) - user_v1 = UserModelV1(**values) - # BriefAlbumProtocol assert isinstance(brief_album, BriefAlbumProtocol) - assert isinstance(album_v1, BriefAlbumProtocol) # BriefArtistprotocol assert isinstance(brief_artist, BriefArtistProtocol) - assert isinstance(artist_v1, BriefArtistProtocol) # BriefSongProtocol assert isinstance(brief_song, BriefSongModel) assert isinstance(song, BriefSongProtocol) - assert isinstance(song_v1, BriefSongProtocol) # SongProtocol assert isinstance(song, SongProtocol) - assert isinstance(song_v1, SongProtocol) # BriefUserProtocol assert isinstance(brief_user, BriefUserProtocol) assert isinstance(user, BriefUserProtocol) - assert isinstance(user_v1, BriefUserProtocol) # UserProtocol - assert isinstance(user_v1, UserProtocol) assert isinstance(user, UserProtocol) # VideoProtocol diff --git a/tests/player/test_playlist.py b/tests/player/test_playlist.py index 7e2d7ebefd..2929facc71 100644 --- a/tests/player/test_playlist.py +++ b/tests/player/test_playlist.py @@ -11,6 +11,8 @@ ) from feeluown.utils.dispatch import Signal +SONG2_URL = 'http://x.mp3' + @pytest.fixture() def pl(app_mock, song, song1): @@ -33,7 +35,6 @@ def pl_prepare_media_none(mocker, pl): @pytest.fixture() def pl_list_standby_return_empty(mocker, pl): - pl._app.library.a_list_song_standby f2 = asyncio.Future() f2.set_result([]) mock_a_list_standby = pl._app.library.a_list_song_standby_v2 @@ -42,9 +43,8 @@ def pl_list_standby_return_empty(mocker, pl): @pytest.fixture() def pl_list_standby_return_song2(mocker, pl, song2): - pl._app.library.a_list_song_standby f2 = asyncio.Future() - f2.set_result([(song2, song2.url)]) + f2.set_result([(song2, SONG2_URL)]) mock_a_list_standby = pl._app.library.a_list_song_standby_v2 mock_a_list_standby.return_value = f2 @@ -135,7 +135,7 @@ async def test_set_current_song_with_bad_song_2( await pl.a_set_current_song(song2) # A song that has no valid media should be marked as bad assert mock_mark_as_bad.called - mock_pure_set_current_song.assert_called_once_with(song2, song2.url, sentinal) + mock_pure_set_current_song.assert_called_once_with(song2, SONG2_URL, sentinal) def test_pure_set_current_song( @@ -143,7 +143,7 @@ def test_pure_set_current_song( # Current song index is 0 assert pl.list().index(song) == 0 # song2 is not in playlist before - pl.pure_set_current_song(song2, song2.url) + pl.pure_set_current_song(song2, SONG2_URL) assert pl.current_song == song2 # The song should be inserted after the current song, # so the index should be 1 diff --git a/tests/serializers/test_serializers.py b/tests/serializers/test_serializers.py index 44d51099c1..3b97da74c0 100644 --- a/tests/serializers/test_serializers.py +++ b/tests/serializers/test_serializers.py @@ -1,10 +1,7 @@ -import pytest - from feeluown.app import App from feeluown.player import Player, Playlist from feeluown.serializers import serialize from feeluown.library import SongModel -from feeluown.models import SongModel as SongModelV1 from feeluown.player import Metadata @@ -38,10 +35,3 @@ def test_serialize_model(): song_js = serialize('python', song, fetch=True) assert song_js['identifier'] == '1' - - -@pytest.mark.skip(reason='model v1 will be removed lator') -def test_serialize_model_v1(): - song = SongModelV1(identifier='1', title='', artists=[], duration=0) - song_js = serialize('python', song) - assert song_js['identifier'] == '1' diff --git a/tests/server/handlers/test_show.py b/tests/server/handlers/test_show.py deleted file mode 100644 index f31d281d3c..0000000000 --- a/tests/server/handlers/test_show.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest - -from feeluown.server.handlers.show import router -from feeluown.library.provider import dummy_provider, Dummy, \ - DummyAlbumModel, DummyArtistModel, DummyPlaylistModel, \ - DummySongModel - - -@pytest.fixture -def ctx(library): - library.register(dummy_provider) - return {'library': library} - - -@pytest.fixture -def handle(ctx): - return lambda path: router.dispatch(path, ctx) - - -def test_cmd_show_model(handle): - song = handle(f'/{Dummy}/songs/{Dummy}') - assert song.identifier == Dummy - - -def test_cmd_show_lyric(handle): - lyric = handle(f'/{Dummy}/songs/{Dummy}/lyric') - assert not lyric - - -def test_cmd_show_artist_albums(mocker, handle): - artist = DummyArtistModel.get(Dummy) - album = DummyAlbumModel.get(Dummy) - artist.albums = [album] - mocker.patch.object(DummyArtistModel, 'get', return_value=artist) - - albums = handle(f'/{Dummy}/artists/{Dummy}/albums') - assert len(albums) == 1 and albums[0] == album - - -def test_cmd_show_playlist_songs(mocker, handle): - playlist = DummyPlaylistModel.get(Dummy) - song = DummySongModel.get(Dummy) - playlist.songs = [song] - mocker.patch.object(DummyPlaylistModel, 'get', return_value=playlist) - - songs = handle(f'/{Dummy}/playlists/{Dummy}/songs') - assert len(songs) == 1 and songs[0] == song diff --git a/tests/test_collection.py b/tests/test_collection.py index 4e0cb5d6d3..ecdfd929e8 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -1,4 +1,4 @@ -from feeluown.models.uri import ResolveFailed, ResolverNotFound, reverse +from feeluown.library import ResolveFailed, ResolverNotFound, reverse from feeluown.collection import Collection, CollectionManager, LIBRARY_FILENAME, \ POOL_FILENAME diff --git a/tests/test_model.py b/tests/test_model.py deleted file mode 100644 index 9658fc8bde..0000000000 --- a/tests/test_model.py +++ /dev/null @@ -1,98 +0,0 @@ -from collections import namedtuple -from unittest import TestCase - -from feeluown.models import Model, BaseModel, display_property -from feeluown.models import AlbumModel, AlbumType - - -class FakeProvider: - identifier = 'fake' - name = 'fake' - - def set_model_cls(self, *args, **kwags): - pass - - -provider = FakeProvider() - - -class TestModel(TestCase): - - def test_meta_class(self): - - class SongModel(Model): - class Meta: - provider = provider - - song = SongModel() - self.assertEqual(song.meta.provider.name, 'fake') - - def test_meta_class_inherit(self): - class SongModel(Model): - class Meta: - model_type = 1 # song model - - class LastSongModel(SongModel): - pass - - song = LastSongModel() - self.assertEqual(song.meta.model_type, 1) - - def test_meta_class_inherit_with_override(self): - class SongModel(Model): - class Meta: - model_type = 1 # song model - - class LastSongModel(SongModel): - class Meta: - provider = provider - - song = LastSongModel() - self.assertEqual(song.meta.model_type, 1) - self.assertEqual(song.meta.provider.name, 'fake') - - -class TestBaseModel(TestCase): - def test_display_fields(self): - class SongModel(BaseModel): - class Meta: - fields = ['title', 'album'] - fields_display = ['album_name'] - - @property - def album_name(self): - return self.album.name if self.album else '' - - album_name = 'Minutes-to-Midnight' - song = SongModel.create_by_display(identifier=1, album_name=album_name) - self.assertEqual(song.album_name_display, album_name) - self.assertEqual(song.album_name, '') - - real_album_name = 'Minutes to Midnight' - song.title = 'Leave out all the rest' - Album = namedtuple('Album', ('name', )) - song.album = Album(real_album_name) - song.use_display = False - self.assertEqual(song.album_name, real_album_name) - - -class TestDisplayProperty(TestCase): - def test_display_basic_usage(self): - class A: - stage = 4 - a_display = display_property('a') - - a1 = A() - a2 = A() - self.assertEqual(a1.a_display, '') - a2.a_display = 'a2' - self.assertEqual(a1.a_display, '') - self.assertEqual(a2.a_display, 'a2') - - -def test_album_model_default_type(): - album = AlbumModel(identifier=1) - assert album.type == AlbumType.standard - - album2 = AlbumModel.create_by_display(identifier=2) - assert album2.type == AlbumType.standard diff --git a/tests/test_model_base.py b/tests/test_model_base.py deleted file mode 100644 index c360d0f687..0000000000 --- a/tests/test_model_base.py +++ /dev/null @@ -1,149 +0,0 @@ -from feeluown.models import Model as Struct, Model, ModelExistence - - -def test_basic_usage(): - class User(Struct): - class Meta: - fields = ['name', 'age'] - - user = User(name='Tom', age=10) - assert user.name == 'Tom' - assert user.age == 10 - - -def test_inherit_usage(): - class Lang(Struct): - class Meta: - fields = ['name'] - - class Python(Lang): - class Meta: - fields = ['author'] - - assert 'name' in Python._meta.fields - p = Python(name='Python', author='Guido') - assert p.author == 'Guido' - assert p.name == 'Python' - - -def test_init_with_obj(): - class User(Struct): - class Meta: - fields = ['name'] - - u1 = User(name='haha') - u2 = User(u1) - assert u2.name == 'haha' - - -def test_customize_init(): - class User(Struct): - class Meta: - fields = ['name'] - - def __init__(self, **kwargs): - super(User, self).__init__(**kwargs) - - class VIP(User): - class Meta: - fields = ['level'] - - def __init__(self, **kwargs): - super(VIP, self).__init__(**kwargs) - - u = VIP(name='haha', level=1) - assert u.name == 'haha' - - -def test_customize_init_2(): - class User(Struct): - class Meta: - fields = ['name', 'age'] - - def __init__(self, name, **kwargs): - super(User, self).__init__(name=name, **kwargs) - - user = User(name='hello', age=10) - assert user.name == 'hello' - assert user.age == 10 - - -def test_mix(): - class User(Struct): - class Meta: - fields = ['name', 'age', 'birthday'] - - user = User(name='lucy', age=20, birthday='2017') - - class VIP(User): - class Meta: - fields = ['level'] - - vip = VIP(user, level=1) - vip2 = VIP(name='ysw', level=1) - - assert vip.name == 'lucy' - assert vip.level == 1 - assert vip2.name == 'ysw' - assert vip2.level == 1 - assert vip2.age is None - - -def test_mixins(): - class User(Struct): - class Meta: - fields = ['name', 'age', 'birthday'] - - class Hacker(Struct): - class Meta: - fields = ['alias'] - - class Student(User, Hacker): - pass - - s = Student(name='ysw', alias='cosven') - assert s.name == 'ysw' - assert s.alias == 'cosven' - - -def test_init_with_part_kwargs(): - class User(Struct): - class Meta: - fields = ['name', 'age', 'birthday'] - - u = User(name='ysw') - assert u.age is None - - -def test_model_init_with_kwargs(): - class XModel(Model): - class Meta: - fields = ['a'] - - xmodel = XModel(a=1, b=2) - assert xmodel.a == 1 - assert not hasattr(xmodel, 'b') - - -def test_model_init_with_model(): - - class XModel(Model): - class Meta: - fields = ['a', 'b'] - fields_display = ['b'] - - class YModel(Model): - class Meta: - fields = ['a', 'b', 'c'] - fields_display = ['b', 'c'] - - xmodel = XModel.create_by_display(identifier=1, b='miao') - assert xmodel.a is None - xmodel.exists = ModelExistence.yes - - ymodel = YModel(xmodel, a=1, c=3, d=4) - assert ymodel.exists is ModelExistence.yes - assert ymodel.b_display == 'miao' - assert ymodel.a == 1 - assert ymodel.c == 3 - assert not hasattr(ymodel, 'd') diff --git a/tests/test_model_uri.py b/tests/test_model_uri.py deleted file mode 100644 index 66408c8aa0..0000000000 --- a/tests/test_model_uri.py +++ /dev/null @@ -1,63 +0,0 @@ -from feeluown.models import BaseModel, ModelType, resolve, Resolver, reverse -from feeluown.library.provider import DummySongModel, DummyAlbumModel, DummyArtistModel - - -IMG_DATA = b'img data' - - -class XAlbumModel(BaseModel): - source = 'fake' # FakeProvider in conftest - - class Meta: - model_type = ModelType.album.value - fields = ['name', 'songs', 'desc', 'img'] - paths = [ - '/img/data', - ] - - def resolve__img_data(self, **kwargs): - return IMG_DATA - - -def test_resolve(event_loop, library): - Resolver.loop = event_loop - Resolver.library = library - - album = XAlbumModel() - result = resolve('/img/data', model=album) - assert result == IMG_DATA - - -def test_reverse(): - artist = DummyArtistModel(identifier=1, name='孙燕姿') - album = DummyAlbumModel(identifier=1, name='逆光', artists=[artist]) - song = DummySongModel(identifier=1, - title='我怀念的', - artists=[artist], - duration=0, - album=album) - - # reverse various song model - assert reverse(song, as_line=True) == \ - 'fuo://dummy/songs/1\t# 我怀念的 - 孙燕姿 - 逆光 - 00:00' - song_with_no_artist_album = DummySongModel(identifier=1, - title='我怀念的') - assert reverse(song_with_no_artist_album, as_line=True) == \ - 'fuo://dummy/songs/1\t# 我怀念的 - "" - "" - 00:00' - song_with_nothing = DummySongModel(identifier=1) - assert reverse(song_with_nothing, as_line=True) == \ - 'fuo://dummy/songs/1\t# "" - "" - "" - 00:00' - - song_display = DummySongModel.create_by_display(identifier=1) - assert reverse(song_display, as_line=True) == \ - 'fuo://dummy/songs/1' - - # reverse various album model - album_with_nothing = DummyAlbumModel(identifier=1) - assert reverse(album_with_nothing, as_line=True) == \ - 'fuo://dummy/albums/1' - album_with_no_artist = DummyAlbumModel(identifier=1, name='逆光') - assert reverse(album_with_no_artist, as_line=True) == \ - 'fuo://dummy/albums/1\t# 逆光' - assert reverse(album, as_line=True) == \ - 'fuo://dummy/albums/1\t# 逆光 - 孙燕姿'