def test_connect1(self): with mock.patch.object(A, 'f', return_value=None) as mock_method_f: s = Signal() # pay attention self.assertTrue(self.a1.f == self.a2.f == mock_method_f) s.connect(self.a1.f) s.emit(1, 'hello') mock_method_f.assert_called_once_with(1, 'hello')
class Request: def __init__(self): self.connected_signal = Signal() self.disconnected_signal = Signal() self.slow_signal = Signal() self.server_error_signal = Signal() def get(self, *args, **kw): logger.info('request.get %s %s' % (args, kw)) if kw.get('timeout') is None: kw['timeout'] = 1 try: res = requests.get(*args, **kw) self.connected_signal.emit() return res except ConnectionError: self.disconnected_signal.emit() except HTTPError: self.server_error_signal.emit() except Timeout: self.slow_signal.emit() return None def post(self, *args, **kw): logger.info('request.post %s %s' % (args, kw)) try: res = requests.post(*args, **kw) return res except ConnectionError: self.disconnected_signal.emit() except HTTPError: self.server_error_signal.emit() except Timeout: self.slow_signal.emit() return None
def test_disconnect(self): s = Signal() s.connect(f) s.disconnect(f) s.emit(1, 'hello')
def test_connect2(self): s = Signal() s.connect(f) s.emit(1, 'hello') s.emit(1, 'hello')
class Library: """音乐库,管理资源提供方以及资源""" def __init__(self, providers_standby=None): """ :type app: feeluown.app.App """ self._providers_standby = providers_standby self._providers = set() self.provider_added = Signal() # emit(AbstractProvider) self.provider_removed = Signal() # emit(AbstractProvider) def register(self, provider): """register 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.library.ProviderAlreadyExists """ if not isinstance(provider, AbstractProvider): raise ValueError('invalid provider instance') for _provider in self._providers: if _provider.identifier == provider.identifier: raise ProviderAlreadyExists self._providers.add(provider) self.provider_added.emit(provider) def deregister(self, provider): """deregister provider""" try: self._providers.remove(provider) except ValueError: raise ProviderNotFound from None else: self.provider_removed.emit(provider) def get(self, identifier): """通过资源提供方唯一标识获取提供方实例""" for provider in self._providers: if provider.identifier == identifier: return provider return None def list(self): """列出所有资源提供方""" return list(self._providers) def _filter(self, identifier_in=None): if identifier_in is None: return iter(self._providers) return filter(lambda p: p.identifier in identifier_in, self.list()) def search(self, keyword, type_in=None, source_in=None, **kwargs): """search song/artist/album/playlist by keyword please use a_search method if you can. :param keyword: search keyword :param type_in: search type :param source_in: None or provider identifier list - TODO: support search with filters(by artist or by source) """ type_in = SearchType.batch_parse(type_in) if type_in else [ SearchType.so ] for provider in self._filter(identifier_in=source_in): for type_ in type_in: try: result = provider.search(keyword=keyword, type_=type_, **kwargs) except Exception: # pylint: disable=broad-except logger.exception('Search %s in %s failed.', keyword, provider) else: if result is not None: yield result async def a_search(self, keyword, source_in=None, timeout=None, type_in=None, **kwargs): """async version of search TODO: add Happy Eyeballs requesting strategy if needed """ type_in = SearchType.batch_parse(type_in) if type_in else [ SearchType.so ] fs = [] # future list for provider in self._filter(identifier_in=source_in): for type_ in type_in: future = aio.run_in_executor( None, partial(provider.search, keyword, type_=type_)) fs.append(future) results = [] # TODO: use async generator when we only support Python 3.6 or above for future in aio.as_completed(fs, timeout=timeout): try: result = await future except Exception as e: logger.exception(str(e)) else: if result is not None: results.append(result) return results @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): """async version of list_song_standby """ providers = self._providers_standby or [ pvd.identifier for pvd in self.list() ] valid_providers = [ provider for provider in providers if provider != song.source ] q = '{} {}'.format(song.title_display, song.artists_name_display) result_g = await self.a_search(q, source_in=valid_providers) 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: url = await aio.run_in_executor(None, lambda: standby.url) if url: result.append(standby) if onlyone or len(result) >= 2: break return result
class Playlist: """player playlist provide a list of song model to play """ def __init__(self, songs=None, playback_mode=PlaybackMode.loop, audio_select_policy='hq<>'): """ :param songs: list of :class:`feeluown.models.SongModel` :param playback_mode: :class:`feeluown.player.PlaybackMode` """ #: store value for ``current_song`` property self._current_song = None #: songs whose url is invalid self._bad_songs = DedupList() #: store value for ``songs`` property self._songs = DedupList(songs or []) self.audio_select_policy = audio_select_policy #: store value for ``playback_mode`` property self._playback_mode = playback_mode #: playback mode changed signal self.playback_mode_changed = Signal() self.song_changed = Signal() """current song changed signal The player will play the song after it receive the signal, when song is None, the player will stop playback. """ self.song_changed_v2 = Signal() """current song chagned signal, v2 emit(song, media) """ def __len__(self): return len(self._songs) def __getitem__(self, index): """overload [] operator""" return self._songs[index] def mark_as_bad(self, song): if song in self._songs and song not in self._bad_songs: self._bad_songs.append(song) def add(self, song): """往播放列表末尾添加一首歌曲""" if song in self._songs: return self._songs.append(song) logger.debug('Add %s to player playlist', song) def insert(self, song): """在当前歌曲后插入一首歌曲""" if song in self._songs: return if self._current_song is None: self._songs.append(song) else: index = self._songs.index(self._current_song) self._songs.insert(index + 1, song) def remove(self, song): """Remove song from playlist. O(n) If song is current song, remove the song and play next. Otherwise, just remove it. """ if song in self._songs: if self._current_song is None: self._songs.remove(song) elif song == self._current_song: next_song = self.next_song # 随机模式下或者歌单只剩一首歌曲,下一首可能和当前歌曲相同 if next_song == self.current_song: self.current_song = None self._songs.remove(song) self.current_song = self.next_song else: next_song = self.next_song self._songs.remove(song) self.current_song = next_song else: self._songs.remove(song) logger.debug('Remove {} from player playlist'.format(song)) else: logger.debug('Remove failed: {} not in playlist'.format(song)) if song in self._bad_songs: self._bad_songs.remove(song) def init_from(self, songs): """(alpha) temporarily, should only called by player.play_songs""" self.clear() # since we will call songs.clear method during playlist clearing, # we need to deepcopy songs object here. self._songs = DedupList(copy.deepcopy(songs)) def clear(self): """remove all songs from playlists""" if self.current_song is not None: self.current_song = None self._songs.clear() self._bad_songs.clear() def list(self): """get all songs in playlists""" return self._songs @property def current_song(self): """ current playing song, return None if there is no current song """ return self._current_song @current_song.setter def current_song(self, song): """设置当前歌曲,将歌曲加入到播放列表,并发出 song_changed 信号 .. note:: 该方法理论上只应该被 Player 对象调用。 """ media = None if song is not None: media = self.prepare_media(song) self._set_current_song(song, media) def _set_current_song(self, song, media): if song is None: self._current_song = None else: # add it to playlist if song not in playlist if song in self._songs: self._current_song = song else: self.insert(song) self._current_song = song self.song_changed.emit(song) self.song_changed_v2.emit(song, media) def prepare_media(self, song): """prepare media data """ warnings.warn('use library.song_prepare_media please', DeprecationWarning) if song.meta.support_multi_quality: media, quality = song.select_media(self.audio_select_policy) else: media = song.url # maybe a empty string return Media(media) if media else None @property def playback_mode(self): return self._playback_mode @playback_mode.setter def playback_mode(self, playback_mode): self._playback_mode = playback_mode self.playback_mode_changed.emit(playback_mode) def _get_good_song(self, base=0, random_=False, direction=1, loop=True): """从播放列表中获取一首可以播放的歌曲 :param base: base index :param random: random strategy or not :param direction: forward if > 0 else backward :param loop: regard the song list as a loop >>> pl = Playlist([1, 2, 3]) >>> pl._get_good_song() 1 >>> pl._get_good_song(base=1) 2 >>> pl._bad_songs = [2] >>> pl._get_good_song(base=1, direction=-1) 1 >>> pl._get_good_song(base=1) 3 >>> pl._bad_songs = [1, 2, 3] >>> pl._get_good_song() """ if not self._songs or len(self._songs) <= len(self._bad_songs): logger.debug('No good song in playlist.') return None good_songs = [] if direction > 0: if loop is True: song_list = self._songs[base:] + self._songs[0:base] else: song_list = self._songs[base:] else: if loop is True: song_list = self._songs[base::-1] + self._songs[:base:-1] else: song_list = self._songs[base::-1] for song in song_list: if song not in self._bad_songs: good_songs.append(song) if not good_songs: return None if random_: return random.choice(good_songs) else: return good_songs[0] @property def next_song(self): """next song for player, calculated based on playback_mode""" # 如果没有正在播放的歌曲,找列表里面第一首能播放的 if self.current_song is None: return self._get_good_song() if self.playback_mode == PlaybackMode.random: next_song = self._get_good_song(random_=True) else: current_index = self._songs.index(self.current_song) if current_index == len(self._songs) - 1: if self.playback_mode in (PlaybackMode.loop, PlaybackMode.one_loop): next_song = self._get_good_song() elif self.playback_mode == PlaybackMode.sequential: next_song = None else: next_song = self._get_good_song(base=current_index + 1, loop=False) return next_song @property def previous_song(self): """previous song for player to play NOTE: not the last played song """ if self.current_song is None: return self._get_good_song(base=-1, direction=-1) if self.playback_mode == PlaybackMode.random: previous_song = self._get_good_song(direction=-1) else: current_index = self._songs.index(self.current_song) previous_song = self._get_good_song(base=current_index - 1, direction=-1) return previous_song def next(self): """advance to the next song in playlist""" self.current_song = self.next_song def previous(self): """return to the previous song in playlist""" self.current_song = self.previous_song
class AbstractPlayer(metaclass=ABCMeta): """Player abstrace base class""" def __init__(self, _=None, **kwargs): """ :param _: keep this arg to keep backward compatibility """ self._position = 0 # seconds self._volume = 100 # (0, 100) self._playlist = None self._state = State.stopped self._duration = None self._current_media = None self._current_metadata = Metadata() #: player position changed signal self.position_changed = Signal() self.seeked = Signal() #: player state changed signal self.state_changed = Signal() #: current media finished signal self.media_finished = Signal() #: duration changed signal self.duration_changed = Signal() #: media about to change: (old_media, media) self.media_about_to_changed = Signal() #: media changed signal self.media_changed = Signal() self.media_loaded = Signal() self.metadata_changed = Signal() #: volume changed signal: (int) self.volume_changed = Signal() @property def state(self): """Player state :rtype: State """ return self._state @state.setter def state(self, value): """set player state, emit state changed signal outer object should not set state directly, use ``pause`` / ``resume`` / ``stop`` / ``play`` method instead. """ self._state = value self.state_changed.emit(value) @property def current_media(self): return self._current_media @property def current_metadata(self) -> Metadata: """Metadata for the current media Check `MetadataFields` for all possible fields. Note that some fields can be missed if them are unknown. For example, a video's metadata may have no genre info. """ return self._current_metadata @property def position(self): """player position, the units is seconds""" return self._position @position.setter def position(self, position): """set player position, the units is seconds""" @property def volume(self): return self._volume @volume.setter def volume(self, value): value = 0 if value < 0 else value value = 100 if value > 100 else value self._volume = value self.volume_changed.emit(value) @property def duration(self): """player media duration, the units is seconds""" return self._duration @duration.setter def duration(self, value): value = value or 0 if value != self._duration: self._duration = value self.duration_changed.emit(value) @abstractmethod def play(self, media, video=True, metadata=None): """play media :param media: a local file absolute path, or a http url that refers to a media file :param video: show video or not :param metadata: metadata for the media """ @abstractmethod def set_play_range(self, start=None, end=None): pass @abstractmethod def resume(self): """play playback""" @abstractmethod def pause(self): """pause player""" @abstractmethod def toggle(self): """toggle player state""" @abstractmethod def stop(self): """stop player""" @abstractmethod def shutdown(self): """shutdown player, do some clean up here""" # ------------------ # Deprecated methods # ------------------ @property def playlist(self): """(DEPRECATED) player playlist Player SHOULD not know the existence of playlist. However, in the very beginning, the player depends on playlist and listen playlist's signal. Other programs may depends on the playlist property and we keep it for backward compatibility. TODO: maybe add a DeprecationWarning in v3.8. :return: :class:`.Playlist` """ return self._playlist def set_playlist(self, playlist): self._playlist = playlist @property def current_song(self): """(Deprecated) alias of playlist.current_song Please use playlist.current_song instead. """ warnings.warn('use playlist.current_model instead', DeprecationWarning) return self._playlist.current_song def load_song(self, song) -> asyncio.Task: """加载歌曲 如果目标歌曲与当前歌曲不相同,则修改播放列表当前歌曲, 播放列表会发出 song_changed 信号,player 监听到信号后调用 play 方法, 到那时才会真正的播放新的歌曲。如果和当前播放歌曲相同,则忽略。 .. note:: 调用方应该直接调用 playlist.current_song = song 来切换歌曲 """ assert song is not None warnings.warn( 'use playlist.set_current_model instead, this will be removed in v3.8', DeprecationWarning) return self._playlist.set_current_song(song) def play_song(self, song): """加载并播放指定歌曲""" warnings.warn( 'use playlist.set_current_model instead, this will be removed in v3.8', DeprecationWarning) return self._playlist.set_current_song(song) def play_songs(self, songs): """(alpha) play list of songs""" warnings.warn( 'use playlist.init_from instead, this will be removed in v3.8', DeprecationWarning) self.playlist.set_models(songs, next_=True)
class Library: """音乐库,管理资源提供方以及资源""" def __init__(self, providers_standby=None): """ :type app: feeluown.app.App """ self._providers_standby = providers_standby self._providers = set() self.provider_added = Signal() # emit(AbstractProvider) self.provider_removed = Signal() # emit(AbstractProvider) def register(self, provider): """register 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') for _provider in self._providers: if _provider.identifier == provider.identifier: raise ProviderAlreadyExists self._providers.add(provider) self.provider_added.emit(provider) def deregister(self, provider): """deregister provider""" try: self._providers.remove(provider) except ValueError: raise ProviderNotFound from None else: self.provider_removed.emit(provider) def get(self, identifier) -> Optional[Union[AbstractProvider, ProviderV2]]: """通过资源提供方唯一标识获取提供方实例""" for provider in self._providers: if provider.identifier == identifier: return provider return None def list(self): """列出所有资源提供方""" return list(self._providers) def _filter(self, identifier_in=None): if identifier_in is None: return iter(self._providers) return filter(lambda p: p.identifier in identifier_in, self.list()) def search(self, keyword, type_in=None, source_in=None, **kwargs): """search song/artist/album/playlist by keyword please use a_search method if you can. :param keyword: search keyword :param type_in: search type :param source_in: None or provider identifier list - TODO: support search with filters(by artist or by source) """ type_in = SearchType.batch_parse(type_in) if type_in else [SearchType.so] for provider in self._filter(identifier_in=source_in): for type_ in type_in: try: result = provider.search(keyword=keyword, type_=type_, **kwargs) except Exception: # pylint: disable=broad-except logger.exception('Search %s in %s failed.', keyword, provider) else: if result is not None: yield result async def a_search(self, keyword, source_in=None, timeout=None, type_in=None, **kwargs): """async version of search TODO: add Happy Eyeballs requesting strategy if needed """ type_in = SearchType.batch_parse(type_in) if type_in else [SearchType.so] fs = [] # future list for provider in self._filter(identifier_in=source_in): for type_ in type_in: future = aio.run_in_executor( None, partial(provider.search, keyword, type_=type_)) fs.append(future) for future in aio.as_completed(fs, timeout=timeout): try: result = await future except: # noqa logger.exception('search task failed') continue else: # When a provider does not implement search method, it returns 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): """list song standbys and their media .. versionadded:: 3.7.8 """ async def prepare_media(standby, policy): media = None try: media = await aio.run_in_executor(None, self.song_prepare_media, standby, policy) except MediaNotFound: pass except: # noqa logger.exception(f'get standby:{standby} media failed') return media 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)] if score_fn is None: score_fn = default_score_fn limit = max(limit, 1) q = '{} {}'.format(song.title_display, song.artists_name_display) standby_score_list = [] # [(standby, score), (standby, score)] song_media_list = [] # [(standby, media), (standby, media)] async for result in self.a_search(q, source_in=pvd_ids): if result is None: continue # Only check the first 3 songs for standby in result.songs: score = score_fn(song, standby) if score == FULL_SCORE: media = await prepare_media(standby, audio_select_policy) if media is None: continue logger.info(f'find full mark standby for song:{q}') song_media_list.append((standby, media)) if len(song_media_list) >= limit: # Return as early as possible to get better performance return song_media_list elif score >= min_score: standby_score_list.append((standby, score)) # Limit try times since prapare_media is an expensive IO operation max_try = len(pvd_ids) * 2 for standby, score in sorted(standby_score_list, key=lambda song_score: song_score[1], reverse=True)[:max_try]: media = await prepare_media(standby, audio_select_policy) if media is not None: song_media_list.append((standby, media)) if len(song_media_list) >= limit: return song_media_list return song_media_list # # methods for v2 # # provider common def get_or_raise(self, identifier) -> Union[AbstractProvider, ProviderV2]: """ :raises ProviderNotFound: """ provider = self.get(identifier) if provider is None: raise ProviderNotFound(f'provider {identifier} not found') return provider def getv2_or_raise(self, identifier) -> ProviderV2: provider = self.get_or_raise(identifier) # You should ensure the provider is v2 first. For example, if check_flags # returns true, the provider must be a v2 instance. assert isinstance(provider, ProviderV2), 'provider must be v2' return provider def check_flags(self, source: str, model_type: ModelType, flags: PF) -> bool: """Check if a provider satisfies the specific ability for a model type .. note:: Currently, we use ProviderFlags to define which ability a provider has. In the future, we may use typing.Protocol. So you should use :meth:`check_flags` method to check ability instead of compare provider flags directly. """ provider = self.get(source) if provider is None: return False if isinstance(provider, ProviderV2): return provider.check_flags(model_type, flags) # Always return false when the provider is not a v2 instance. return False def check_flags_by_model(self, model: ModelProtocol, flags: PF) -> bool: """Alias for check_flags""" return self.check_flags(model.source, ModelType(model.meta.model_type), flags) # --------------------------- # Methods for backward compat # --------------------------- def cast_model_to_v1(self, model): """Cast a v1/v2 model to v1 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 = str(ModelType(model.meta.model_type)) raise NotSupported(f'provider has no v1 model impl for {model_type_str}') kv = {} for field in ModelCls.meta.fields_display: kv[field] = getattr(model, field) return ModelCls.create_by_display(identifier=model.identifier, **kv) # ----- # Songs # ----- def song_upgrade(self, song: BriefSongProtocol) -> SongProtocol: return self._model_upgrade(song) # type: ignore def song_list_similar(self, song: BriefSongProtocol) -> List[BriefSongProtocol]: provider = self.getv2_or_raise(song.source) return provider.song_list_similar(song) def song_list_hot_comments(self, song: BriefSongProtocol): provider = self.getv2_or_raise(song.source) return provider.song_list_hot_comments(song) def song_prepare_media(self, song: BriefSongProtocol, policy) -> Media: provider = self.get(song.source) if provider is None: # FIXME: raise ProviderNotfound raise MediaNotFound(f'provider:{song.source} not found') if song.meta.flags & MF.v2: # provider MUST has multi_quality flag for song assert self.check_flags_by_model(song, PF.multi_quality) assert isinstance(provider, ProviderV2) media, _ = provider.song_select_media(song, policy) else: if song.meta.support_multi_quality: media, _ = song.select_media(policy) # type: ignore else: url = song.url # type: ignore if url: media = Media(url) else: raise MediaNotFound if not media: raise MediaNotFound return media def song_prepare_mv_media(self, song: BriefSongProtocol, policy) -> Media: """ .. versionadded:: 3.7.5 """ song_v1 = self.cast_model_to_v1(song) mv = song_v1.mv if mv.meta.support_multi_quality: media, _ = mv.select_media(policy) else: media = mv.media if media: media = Media(media) else: media = None if not media: raise MediaNotFound return media def song_get_mv(self, song: BriefSongProtocol) -> Optional[VideoProtocol]: """ :raises NotSupported: :raises ProviderNotFound: """ provider = self.get_or_raise(song.source) if self.check_flags_by_model(song, PF.mv): assert isinstance(provider, ProviderV2) mv = provider.song_get_mv(song) else: song_v1 = self.cast_model_to_v1(song) mv = song_v1.mv mv = cast(Optional[VideoProtocol], mv) return mv def song_get_lyric(self, song: BriefSongModel) -> Optional[LyricProtocol]: """ Return None when lyric does not exist instead of raising exceptions, because it is predictable. :raises NotSupported: :raises ProviderNotFound: """ provider = self.get_or_raise(song.source) if self.check_flags_by_model(song, PF.lyric): assert isinstance(provider, ProviderV2) lyric = provider.song_get_lyric(song) else: song_v1 = self.cast_model_to_v1(song) lyric_v1 = song_v1.lyric lyric = cast(Optional[LyricProtocol], lyric_v1) return lyric def song_get_web_url(self, song: BriefSongProtocol) -> str: provider = self.getv2_or_raise(song.source) return provider.song_get_web_url(song) # -------- # Album # -------- def album_upgrade(self, album: BriefAlbumProtocol): return self._model_upgrade(album) # -------- # Artist # -------- def artist_upgrade(self, artist: BriefArtistProtocol): return self._model_upgrade(artist) def artist_create_songs_rd(self, artist): """Create songs reader for artist model. :raises NotSupported: """ provider = self.get_or_raise(artist.source) if self.check_flags_by_model(artist, PF.songs_rd): assert isinstance(provider, ProviderV2) reader = provider.artist_create_songs_rd(artist) else: artist = self.cast_model_to_v1(artist) if artist.meta.allow_create_songs_g: reader = create_reader(artist.create_songs_g()) else: reader = create_reader(artist.songs) return reader def artist_create_albums_rd(self, artist): """Create albums reader for artist model. """ provider = self.get_or_raise(artist.source) if self.check_flags_by_model(artist, PF.albums_rd): assert isinstance(provider, ProviderV2) reader = provider.artist_create_albums_rd(artist) else: artist = self.cast_model_to_v1(artist) if artist.meta.allow_create_albums_g: reader = create_reader(artist.create_albums_g()) else: raise NotSupported("can't create albums reader for artist") return reader # -------- # Playlist # -------- def playlist_upgrade(self, playlist): return self._model_upgrade(playlist) def playlist_create_songs_rd(self, playlist): """Create songs reader for artist model. :raises NotSupported: """ provider = self.get_or_raise(playlist.source) if self.check_flags_by_model(playlist, PF.songs_rd): assert isinstance(provider, ProviderV2) reader = provider.playlist_create_songs_rd(playlist) else: playlist = self.cast_model_to_v1(playlist) if playlist.meta.allow_create_songs_g: reader = create_reader(playlist.create_songs_g()) else: reader = create_reader(playlist.songs) return reader def playlist_remove_song(self, playlist, song) -> bool: """Remove a song from the playlist :return: true if the song is not in playlist anymore. """ provider = self.get_or_raise(playlist.source) if self.check_flags_by_model(playlist, PF.remove_song): assert isinstance(provider, ProviderV2) ok = provider.playlist_remove_song(playlist, song) else: playlist = self.cast_model_to_v1(playlist) ok = playlist.remove(song.identifier) return ok def playlist_add_song(self, playlist, song) -> bool: """Add a song to the playlist :return: true if the song exists in playlist. """ provider = self.get_or_raise(playlist.source) if self.check_flags_by_model(playlist, PF.remove_song): assert isinstance(provider, ProviderV2) ok = provider.playlist_add_song(playlist, song) else: playlist = self.cast_model_to_v1(playlist) ok = playlist.add(song.identifier) return ok # ------------------------- # generic methods for model # ------------------------- def model_get(self, pid, mtype, mid): """Get a (normal) model instance. :param pid: provider id :param mtype: model type :param mid: model id :return: model :raise NotSupported: provider has not .get for this model type :raise ResourceNotFound: model does not exist """ provider = self.get_or_raise(pid) model = None try_v1way = True if isinstance(provider, ProviderV2): if provider.check_flags(mtype, PF.model_v2): if provider.check_flags(mtype, PF.get): try_v1way = False model = provider.model_get(mtype, mid) # Try to use the ModelV1.get API to get the model. if try_v1way and isinstance(provider, AbstractProvider): try: model_cls = provider.get_model_cls(mtype) model = model_cls.get(mid) except AttributeError: pass if model is None: raise ModelNotFound return model def model_get_cover(self, model): """Get the cover url of model :param model: model which has a 'cover' field. :return: cover url if exists, else ''. """ cover = '' if MF.v2 in model.meta.flags: if MF.normal in model.meta.flags: cover = model.cover else: # TODO: upgrade artist model. # Currently supported model types: (ModelType.album, ModelType.video). if ModelType(model.meta.model_type) in V2SupportedModelTypes: um = self._model_upgrade(model) # FIXME: remove this hack lator. if ModelType(model.meta.model_type) is ModelType.artist: cover = um.pic_url else: cover = um.cover else: cover = model.cover # Check if cover is a media object. if cover and not isinstance(cover, str): cover = cover.url return cover def _model_upgrade(self, model): """ Thinking: currently, the caller must catch the NotSupported error. """ try_v1way = True upgraded_model = None if model.meta.flags & MF.v2: if MF.normal in model.meta.flags: upgraded_model = model try_v1way = False else: provider = self.getv2_or_raise(model.source) # When the provider does not support 'get' for this model. # Do not raise NotSupported here and try to use the v1 way. # # For example, provider X may support 'get' for SongModel, then # the song.artists can return list of BriefArtistModel. if self.check_flags_by_model(model, PF.get): upgraded_model = provider.model_get( model.meta.model_type, model.identifier) try_v1way = False if try_v1way is True: 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) upgraded_model = v1model else: assert upgraded_model is not None return upgraded_model # -------- # Video # -------- def video_prepare_media(self, video: BriefVideoProtocol, policy) -> Media: provider = self.get_or_raise(video.source) if video.meta.flags & MF.v2: # provider MUST has multi_quality flag for video assert self.check_flags_by_model(video, PF.multi_quality) assert isinstance(provider, ProviderV2) media, _ = provider.video_select_media(video, policy) else: # V1 VideoModel has attribute `media` if video.meta.support_multi_quality: media, _ = video.select_media(policy) # type: ignore else: media = video.media # type: ignore if not media: raise MediaNotFound return media # -------- # Provider # -------- def provider_has_current_user(self, source: str) -> bool: """Check if a provider has a logged in user No IO operation is triggered. .. versionadded:: 3.7.6 """ provider = self.get_or_raise(source) if self.check_flags(source, ModelType.none, PF.current_user): assert isinstance(provider, ProviderV2) return provider.has_current_user() try: user_v1 = getattr(provider, '_user') except AttributeError: logger.warn("We can't determine if the provider has a current user") return False else: return user_v1 is not None def provider_get_current_user(self, source: str) -> UserProtocol: """Get provider current logged in user :raises NotSupported: :raises ProviderNotFound: :raises NoUserLoggedIn: .. versionadded:: 3.7.6 """ provider = self.get_or_raise(source) if self.check_flags(source, ModelType.none, PF.current_user): assert isinstance(provider, ProviderV2) return provider.get_current_user() user_v1 = getattr(provider, '_user', None) if user_v1 is None: raise NoUserLoggedIn return UserModel(identifier=user_v1.identifier, source=source, name=user_v1.name_display, avatar_url='')
class AbstractPlayer(metaclass=ABCMeta): """Player abstrace base class""" def __init__(self, playlist=None, **kwargs): self._position = 0 # seconds self._volume = 100 # (0, 100) self._playlist = Playlist() if playlist is None else playlist self._state = State.stopped self._duration = None self._current_media = None #: player position changed signal self.position_changed = Signal() #: player state changed signal self.state_changed = Signal() #: current media finished signal self.media_finished = Signal() #: duration changed signal self.duration_changed = Signal() #: media about to change: (old_media, media) self.media_about_to_changed = Signal() #: media changed signal self.media_changed = Signal() #: volume changed signal: (int) self.volume_changed = Signal() self._playlist.song_changed_v2.connect(self._on_song_changed) self.media_finished.connect(self._on_media_finished) @property def state(self): """Player state :rtype: State """ return self._state @state.setter def state(self, value): """set player state, emit state changed signal outer object should not set state directly, use ``pause`` / ``resume`` / ``stop`` / ``play`` method instead. """ self._state = value self.state_changed.emit(value) @property def current_media(self): return self._current_media @property def current_song(self): """alias of playlist.current_song""" return self._playlist.current_song @property def playlist(self): """player playlist :return: :class:`.Playlist` """ return self._playlist @property def position(self): """player position, the units is seconds""" return self._position @position.setter def position(self, position): """set player position, the units is seconds""" @property def volume(self): return self._volume @volume.setter def volume(self, value): value = 0 if value < 0 else value value = 100 if value > 100 else value self._volume = value self.volume_changed.emit(value) @property def duration(self): """player media duration, the units is seconds""" return self._duration @duration.setter def duration(self, value): if value is not None and value != self._duration: self._duration = value self.duration_changed.emit(value) @abstractmethod def play(self, url, video=True): """play media :param url: a local file absolute path, or a http url that refers to a media file :param video: show video or not """ @abstractmethod def set_play_range(self, start=None, end=None): pass def load_song(self, song): """加载歌曲 如果目标歌曲与当前歌曲不相同,则修改播放列表当前歌曲, 播放列表会发出 song_changed 信号,player 监听到信号后调用 play 方法, 到那时才会真正的播放新的歌曲。如果和当前播放歌曲相同,则忽略。 .. note:: 调用方不应该直接调用 playlist.current_song = song 来切换歌曲 """ if song is not None and song != self.current_song: self._playlist.current_song = song def play_song(self, song): """加载并播放指定歌曲""" self.load_song(song) self.resume() def play_songs(self, songs): """(alpha) play list of songs""" self.playlist.init_from(songs) self.playlist.next() self.resume() @abstractmethod def resume(self): """play playback""" @abstractmethod def pause(self): """pause player""" @abstractmethod def toggle(self): """toggle player state""" @abstractmethod def stop(self): """stop player""" @abstractmethod def shutdown(self): """shutdown player, do some clean up here""" def _on_song_changed(self, song, media): """播放列表 current_song 发生变化后的回调 判断变化后的歌曲是否有效的,有效则播放,否则将它标记为无效歌曲。 如果变化后的歌曲是 None,则停止播放。 """ if song is not None: if media is None: self._playlist.mark_as_bad(song) self._playlist.next() else: self.play(media) else: self.stop() def _on_media_finished(self): if self._playlist.playback_mode == PlaybackMode.one_loop: self.playlist.current_song = self.playlist.current_song else: self._playlist.next()
class Playlist: def __init__(self, app, songs=None, playback_mode=PlaybackMode.loop, audio_select_policy='hq<>'): """ :param songs: list of :class:`feeluown.models.SongModel` :param playback_mode: :class:`feeluown.player.PlaybackMode` """ self._app = app #: init playlist mode normal self._mode = PlaylistMode.normal #: playlist eof signal # playlist have no enough songs self.eof_reached = Signal() #: playlist mode changed signal self.mode_changed = Signal() #: store value for ``current_song`` property self._current_song = None #: songs whose url is invalid self._bad_songs = DedupList() #: store value for ``songs`` property self._songs = DedupList(songs or []) self.audio_select_policy = audio_select_policy #: store value for ``playback_mode`` property self._playback_mode = playback_mode #: playback mode changed signal self.playback_mode_changed = Signal() self.song_changed = Signal() """current song changed signal The player will play the song after it receive the signal, when song is None, the player will stop playback. """ self.song_changed_v2 = Signal() """current song chagned signal, v2 emit(song, media) """ #: When watch mode is on, playlist try to play the mv/video of the song self.watch_mode = False self._t_scm = self._app.task_mgr.get_or_create('set-current-model') # .. versionadded:: 3.7.11 # The *songs_removed* and *songs_added* signal. self.songs_removed = Signal() # (index, count) self.songs_added = Signal() # (index, count) self._app.player.media_finished.connect(self._on_media_finished) @property def mode(self): return self._mode @mode.setter def mode(self, mode): """set playlist mode""" if self._mode is not mode: if mode is PlaylistMode.fm: self.playback_mode = PlaybackMode.sequential # we should change _mode at the very end self._mode = mode self.mode_changed.emit(mode) logger.info('playlist mode changed to %s', mode) def __len__(self): return len(self._songs) def __getitem__(self, index): """overload [] operator""" return self._songs[index] def mark_as_bad(self, song): if song in self._songs and song not in self._bad_songs: self._bad_songs.append(song) def is_bad(self, song): return song in self._bad_songs def _add(self, song): if song in self._songs: return self._songs.append(song) length = len(self._songs) self.songs_added.emit(length - 1, 1) def set_models(self, models, next_=False, fm=False): """ .. versionadded: v3.7.13 """ self.clear() self.batch_add(models) if fm is False: self.mode = PlaylistMode.normal else: self.mode = PlaylistMode.fm if next_ is True: self.next() def batch_add(self, models): """ .. versionadded: v3.7.13 """ start_index = len(self._songs) for model in models: self._songs.append(model) end_index = len(self._songs) self.songs_added.emit(start_index, end_index - start_index) def add(self, song): """add song to playlist Theoretically, when playlist is in FM mode, we should not change songs list manually(without ``fm_add`` method). However, when it happens, we exit FM mode. """ if self._mode is PlaylistMode.fm: self.mode = PlaylistMode.normal self._add(song) def fm_add(self, song): self._add(song) def insert(self, song): """Insert song after current song When current song is none, the song is appended. """ if self._mode is PlaylistMode.fm: self.mode = PlaylistMode.normal if song in self._songs: return if self._current_song is None: self._add(song) else: index = self._songs.index(self._current_song) self._songs.insert(index + 1, song) self.songs_added.emit(index + 1, 1) def remove(self, song): """Remove song from playlist. O(n) If song is current song, remove the song and play next. Otherwise, just remove it. """ try: index = self._songs.index(song) except ValueError: logger.debug('Remove failed: {} not in playlist'.format(song)) else: if self._current_song is None: self._songs.remove(song) elif song == self._current_song: next_song = self.next_song # 随机模式下或者歌单只剩一首歌曲,下一首可能和当前歌曲相同 if next_song == self.current_song: self.current_song = None self._songs.remove(song) self.current_song = self.next_song else: next_song = self.next_song self._songs.remove(song) self.current_song = next_song else: self._songs.remove(song) self.songs_removed.emit(index, 1) logger.debug('Remove {} from player playlist'.format(song)) if song in self._bad_songs: self._bad_songs.remove(song) def init_from(self, songs): warnings.warn('use set_models instead, this will be removed in v3.8', DeprecationWarning) self.set_models(songs, fm=False) def clear(self): """remove all songs from playlists""" if self.current_song is not None: self.current_song = None length = len(self._songs) self._songs.clear() if length > 0: self.songs_removed.emit(0, length) self._bad_songs.clear() def list(self): """Get all songs in playlists""" return self._songs @property def playback_mode(self): return self._playback_mode @playback_mode.setter def playback_mode(self, playback_mode): if self._mode is PlaylistMode.fm: if playback_mode is not PlaybackMode.sequential: logger.warning("can't set playback mode to others in fm mode") return self._playback_mode = playback_mode self.playback_mode_changed.emit(self.playback_mode) def _get_good_song(self, base=0, random_=False, direction=1, loop=True): """从播放列表中获取一首可以播放的歌曲 :param base: base index :param random: random strategy or not :param direction: forward if > 0 else backward :param loop: regard the song list as a loop >>> from unittest import mock >>> pl = Playlist(mock.Mock(), [1, 2, 3]) >>> pl._get_good_song() 1 >>> pl._get_good_song(base=1) 2 >>> pl._bad_songs = [2] >>> pl._get_good_song(base=1, direction=-1) 1 >>> pl._get_good_song(base=1) 3 >>> pl._bad_songs = [1, 2, 3] >>> pl._get_good_song() """ if not self._songs or len(self._songs) <= len(self._bad_songs): logger.debug('No good song in playlist.') return None good_songs = [] if direction > 0: if loop is True: song_list = self._songs[base:] + self._songs[0:base] else: song_list = self._songs[base:] else: if loop is True: song_list = self._songs[base::-1] + self._songs[:base:-1] else: song_list = self._songs[base::-1] for song in song_list: if song not in self._bad_songs: good_songs.append(song) if not good_songs: return None if random_: return random.choice(good_songs) else: return good_songs[0] @property def next_song(self): """next song for player, calculated based on playback_mode""" # 如果没有正在播放的歌曲,找列表里面第一首能播放的 if self.current_song is None: return self._get_good_song() if self.playback_mode == PlaybackMode.random: next_song = self._get_good_song(random_=True) else: current_index = self._songs.index(self.current_song) if current_index == len(self._songs) - 1: if self.playback_mode in (PlaybackMode.loop, PlaybackMode.one_loop): next_song = self._get_good_song() elif self.playback_mode == PlaybackMode.sequential: next_song = None else: next_song = self._get_good_song(base=current_index + 1, loop=False) return next_song @property def previous_song(self): """previous song for player to play NOTE: not the last played song """ if self.current_song is None: return self._get_good_song(base=-1, direction=-1) if self.playback_mode == PlaybackMode.random: previous_song = self._get_good_song(direction=-1, random_=True) else: current_index = self._songs.index(self.current_song) previous_song = self._get_good_song(base=current_index - 1, direction=-1) return previous_song def next(self) -> Optional[asyncio.Task]: if self.next_song is None: self.eof_reached.emit() return None else: return self.set_current_song(self.next_song) def _on_media_finished(self): # Play next model when current media is finished. if self.playback_mode == PlaybackMode.one_loop: return self.set_current_song(self.current_song) else: self.next() def previous(self) -> Optional[asyncio.Task]: """return to the previous song in playlist""" return self.set_current_song(self.previous_song) @property def current_song(self) -> Optional[SongProtocol]: """Current song return None if there is no current song """ return self._current_song @current_song.setter def current_song(self, song: Optional[SongProtocol]): self.set_current_song(song) def set_current_song(self, song) -> Optional[asyncio.Task]: """设置当前歌曲,将歌曲加入到播放列表,并发出 song_changed 信号 .. note:: 该方法理论上只应该被 Player 对象调用。 if song has not valid media, we find a replacement in other providers .. versionadded:: 3.7.11 The method is added to replace current_song.setter. """ if song is None: self.pure_set_current_song(None, None) return None if self.mode is PlaylistMode.fm and song not in self._songs: self.mode = PlaylistMode.normal # FIXME(cosven): `current_song.setter` depends on app.task_mgr and app.library, # which make it hard to test. return self._t_scm.bind_coro(self.a_set_current_song(song)) async def a_set_current_song(self, song): song_str = f'{song.source}:{song.title_display} - {song.artists_name_display}' try: media = await self._prepare_media(song) except MediaNotFound: logger.info(f'{song_str} has no valid media, mark it as bad') self.mark_as_bad(song) # if mode is fm mode, do not find standby song, # just skip the song if self.mode is not PlaylistMode.fm: self._app.show_msg( f'{song_str} is invalid, try to find standby') logger.info(f'try to find standby for {song_str}') standby_candidates = await self._app.library.a_list_song_standby_v2( song, self.audio_select_policy) if standby_candidates: standby, media = standby_candidates[0] self._app.show_msg( f'Song standby was found in {standby.source} ✅') # Insert the standby song after the song if song in self._songs and standby not in self._songs: index = self._songs.index(song) self._songs.insert(index + 1, standby) self.songs_added.emit(index + 1, 1) # NOTE: a_list_song_standby ensure that the song.url is not empty # FIXME: maybe a_list_song_standby should return media directly self.pure_set_current_song(standby, media) else: self._app.show_msg('Song standby not found') self.pure_set_current_song(song, None) else: self.next() except ProviderIOError as e: # FIXME: This may cause infinite loop when the prepare media always fails logger.error(f'prepare media failed: {e}, try next song') self.pure_set_current_song(song, None) except: # noqa # When the exception is unknown, we mark the song as bad. logger.exception('prepare media failed due to unknown error, ' 'so we mark the song as a bad one') self.mark_as_bad(song) self.next() else: assert media, "media must not be empty" self.pure_set_current_song(song, media) def pure_set_current_song(self, song, media): if song is None: self._current_song = None else: # add it to playlist if song not in playlist if song in self._songs: self._current_song = song else: self.insert(song) self._current_song = song self.song_changed.emit(song) self.song_changed_v2.emit(song, media) if song is not None: if media is None: self.next() else: # Note that the value of model v1 {}_display may be None. metadata = Metadata({ MetadataFields.uri: reverse(song), MetadataFields.source: song.source, MetadataFields.title: song.title_display or '', # The song.artists_name should return a list of strings MetadataFields.artists: [song.artists_name_display or ''], MetadataFields.album: song.album_name_display or '', }) kwargs = {} if not self._app.has_gui: kwargs['video'] = False # TODO: set artwork field self._app.player.play(media, metadata=metadata, **kwargs) else: self._app.player.stop() async def _prepare_media(self, song): task_spec = self._app.task_mgr.get_or_create('prepare-media') if self.watch_mode is True: try: mv_media = await task_spec.bind_blocking_io( self._app.library.song_prepare_mv_media, song, self._app.config.VIDEO_SELECT_POLICY) except MediaNotFound: mv_media = None self._app.show_msg('No mv found') except Exception as e: # noqa mv_media = None self._app.show_msg(f'Prepare mv media failed: {e}') if mv_media: return mv_media return await task_spec.bind_blocking_io( self._app.library.song_prepare_media, song, self.audio_select_policy) def set_current_model(self, model): """ .. versionadded: 3.7.13 """ if ModelType(model.meta.model_type) is ModelType.song: return self.set_current_song(model) if model is None: self._app.player.stop() return return self._t_scm.bind_coro(self.a_set_current_model(model)) async def a_set_current_model(self, model): """ TODO: handle when model is a song .. versionadded: 3.7.13 """ assert ModelType(model.meta.model_type) is ModelType.video, \ "{model.meta.model_type} is not supported, expecting a video model, " video = model try: media = await aio.run_fn(self._app.library.video_prepare_media, video, self._app.config.VIDEO_SELECT_POLICY) except MediaNotFound: self._app.show_msg('没有可用的播放链接') else: metadata = Metadata({ # The value of model v1 title_display may be None. MetadataFields.title: video.title_display or '', MetadataFields.source: video.source, MetadataFields.uri: reverse(video), }) kwargs = {} if not self._app.has_gui: kwargs['video'] = False self._app.player.play(media, metadata=metadata, **kwargs) """ Sync methods. Currently, playlist has both async and sync methods to keep backward compatibility. Sync methods will be replaced by async methods in the end. """ def play_model(self, model): """Set current model and play it .. versionadded: 3.7.14 """ task = self.set_current_model(model) if task is not None: def cb(future): try: future.result() except: # noqa logger.exception('play model failed') else: self._app.player.resume() task.add_done_callback(cb)
class PluginsManager: """在 App 初始化完成之后,加载插件 TODO: 以后可能需要支持在 App 初始化完成之前加载插件 """ scan_finished = Signal() def __init__(self, app): super().__init__() self._app = app self._plugins = {} #: A plugin is about to enable. # The payload is the plugin object `(Plugin)`. # .. versionadded: 3.7.15 self.about_to_enable = Signal() def scan(self): """扫描并加载插件""" with self._app.create_action('Scaning plugins'): self._scan_dirs() self._scan_entry_points() self.scan_finished.emit(list(self._plugins.values())) def load_module(self, module): """Load module and try to load the plugin""" with self._app.create_action('Enabling plugin:%s' % module.__name__) as action: # Try to create a new plugin. try: plugin = Plugin.create(module) except InvalidPluginError as e: action.failed(str(e)) return # Try to init config for the plugin. self._plugins[plugin.name] = plugin try: plugin.init_config(self._app.config) except Exception as e: logger.exception(f'Init config for plugin:{plugin.name} failed') action.failed(str(e)) return # Try to enbale the plugin. self.about_to_enable.emit(plugin) try: plugin.enable(self._app) except Exception as e: logger.exception(f'Enable plugin:{plugin.name} failed') action.failed(str(e)) def _scan_dirs(self): """扫描插件目录中的插件""" module_name_list = [] for fname in os.listdir(USER_PLUGINS_DIR): if os.path.isdir(os.path.join(USER_PLUGINS_DIR, fname)): module_name_list.append(fname) else: if fname.endswith('.py'): module_name_list.append(fname[:-3]) sys.path.append(USER_PLUGINS_DIR) for module_name in module_name_list: try: module = importlib.import_module(module_name) except Exception: # noqa logger.exception('Failed to import module %s', module_name) else: self.load_module(module) def _scan_entry_points(self): """扫描通过 setuptools 机制注册的插件 https://packaging.python.org/guides/creating-and-discovering-plugins/ """ try: import importlib.metadata entry_points = importlib.metadata.entry_points().get('fuo.plugins_v1', []) except ImportError: import pkg_resources entry_points = pkg_resources.iter_entry_points('fuo.plugins_v1') for entry_point in entry_points: try: module = entry_point.load() except Exception as e: # noqa logger.exception('Failed to load module %s', entry_point.name) else: self.load_module(module)
class LiveLyric(object): """live lyric LiveLyric listens to song changed signal and position changed signal and emit sentence changed signal. It also has a ``current_sentence`` property. Usage:: live_lyric = LiveLyric() player.song_changed.connect(live_lyric.on_song_changed) player.position_change.connect(live_lyric.on_position_changed) """ def __init__(self, app): self._app = app self.sentence_changed = Signal(str) self._lyric = None self._pos_s_map = {} # position sentence map self._pos_list = [] # position list self._pos = None self._current_sentence = '' @property def current_sentence(self): """get current lyric sentence""" return self._current_sentence @current_sentence.setter def current_sentence(self, value): self._current_sentence = value self.sentence_changed.emit(value) # TODO: performance optimization? def on_position_changed(self, position): """bind position changed signal with this""" if position is None or not self._lyric: return pos = find_previous(position*1000 + 300, self._pos_list) if pos is not None and pos != self._pos: self.current_sentence = self._pos_s_map[pos] self._pos = pos def on_song_changed(self, song): """bind song changed signal with this""" if song is None: self._set_lyric(None) return song = self._app.library.cast_model_to_v1(song) def cb(future): try: lyric = future.result() except: # noqa logger.exception('get lyric failed') lyric = None self._set_lyric(lyric) # TODO: use app.task_mgr instead future = aio.run_in_executor(None, lambda: song.lyric) future.add_done_callback(cb) def _set_lyric(self, lyric): if lyric is None: self._lyric = None self._pos_s_map = {} else: self._lyric = lyric.content self._pos_s_map = parse(self._lyric) self._pos_list = sorted(list(self._pos_s_map.keys())) self._pos = None self.current_sentence = ''
class Playlist: def __init__(self, app, songs=None, playback_mode=PlaybackMode.loop, audio_select_policy='hq<>'): """ :param songs: list of :class:`feeluown.models.SongModel` :param playback_mode: :class:`feeluown.player.PlaybackMode` """ self._app = app #: mainthread asyncio loop ref # We know that feeluown is a asyncio-app, and we can assume # that the playlist is inited in main thread. self._loop = asyncio.get_event_loop() #: init playlist mode normal self._mode = PlaylistMode.normal #: playlist eof signal # playlist have no enough songs self.eof_reached = Signal() #: playlist mode changed signal self.mode_changed = Signal() #: store value for ``current_song`` property self._current_song = None #: songs whose url is invalid self._bad_songs = DedupList() #: store value for ``songs`` property self._songs = DedupList(songs or []) self.audio_select_policy = audio_select_policy #: store value for ``playback_mode`` property self._playback_mode = playback_mode #: playback mode changed signal self.playback_mode_changed = Signal() self.song_changed = Signal() """current song changed signal The player will play the song after it receive the signal, when song is None, the player will stop playback. """ self.song_changed_v2 = Signal() """current song chagned signal, v2 emit(song, media) """ #: When watch mode is on, playlist try to play the mv/video of the song self.watch_mode = False @property def mode(self): return self._mode @mode.setter def mode(self, mode): """set playlist mode""" if self._mode is not mode: if mode is PlaylistMode.fm: self.playback_mode = PlaybackMode.sequential self.clear() # we should change _mode at the very end self._mode = mode self.mode_changed.emit(mode) logger.info('playlist mode changed to %s', mode) def __len__(self): return len(self._songs) def __getitem__(self, index): """overload [] operator""" return self._songs[index] def mark_as_bad(self, song): if song in self._songs and song not in self._bad_songs: self._bad_songs.append(song) def is_bad(self, song): return song in self._bad_songs def _add(self, song): if song in self._songs: return self._songs.append(song) def add(self, song): """add song to playlist Theoretically, when playlist is in FM mode, we should not change songs list manually(without ``fm_add`` method). However, when it happens, we exit FM mode. """ if self._mode is PlaylistMode.fm: self.mode = PlaylistMode.normal self._add(song) def fm_add(self, song): self._add(song) def insert(self, song): """Insert song after current song When current song is none, the song is appended. """ if self._mode is PlaylistMode.fm: self.mode = PlaylistMode.normal if song in self._songs: return if self._current_song is None: self._songs.append(song) else: index = self._songs.index(self._current_song) self._songs.insert(index + 1, song) def remove(self, song): """Remove song from playlist. O(n) If song is current song, remove the song and play next. Otherwise, just remove it. """ if song in self._songs: if self._current_song is None: self._songs.remove(song) elif song == self._current_song: next_song = self.next_song # 随机模式下或者歌单只剩一首歌曲,下一首可能和当前歌曲相同 if next_song == self.current_song: self.current_song = None self._songs.remove(song) self.current_song = self.next_song else: next_song = self.next_song self._songs.remove(song) self.current_song = next_song else: self._songs.remove(song) logger.debug('Remove {} from player playlist'.format(song)) else: logger.debug('Remove failed: {} not in playlist'.format(song)) if song in self._bad_songs: self._bad_songs.remove(song) def init_from(self, songs): """ THINKING: maybe we should rename this method or maybe we should change mode on application level We change playlistmode here because the `player.play_all` call this method. We should check if we need to exit fm mode in `play_xxx`. Currently, we have two play_xxx API: play_all and play_song. 1. play_all -> init_from 2. play_song -> current_song.setter (alpha) temporarily, should only called by player.play_songs """ if self.mode is PlaylistMode.fm: self.mode = PlaylistMode.normal self.clear() # since we will call songs.clear method during playlist clearing, # we need to deepcopy songs object here. self._songs = DedupList(copy.deepcopy(songs)) def clear(self): """remove all songs from playlists""" if self.current_song is not None: self.current_song = None self._songs.clear() self._bad_songs.clear() def list(self): """Get all songs in playlists""" return self._songs @property def playback_mode(self): return self._playback_mode @playback_mode.setter def playback_mode(self, playback_mode): if self._mode is PlaylistMode.fm: if playback_mode is not PlaybackMode.sequential: logger.warning("can't set playback mode to others in fm mode") return self._playback_mode = playback_mode self.playback_mode_changed.emit(self.playback_mode) def _get_good_song(self, base=0, random_=False, direction=1, loop=True): """从播放列表中获取一首可以播放的歌曲 :param base: base index :param random: random strategy or not :param direction: forward if > 0 else backward :param loop: regard the song list as a loop >>> pl = Playlist([1, 2, 3]) >>> pl._get_good_song() 1 >>> pl._get_good_song(base=1) 2 >>> pl._bad_songs = [2] >>> pl._get_good_song(base=1, direction=-1) 1 >>> pl._get_good_song(base=1) 3 >>> pl._bad_songs = [1, 2, 3] >>> pl._get_good_song() """ if not self._songs or len(self._songs) <= len(self._bad_songs): logger.debug('No good song in playlist.') return None good_songs = [] if direction > 0: if loop is True: song_list = self._songs[base:] + self._songs[0:base] else: song_list = self._songs[base:] else: if loop is True: song_list = self._songs[base::-1] + self._songs[:base:-1] else: song_list = self._songs[base::-1] for song in song_list: if song not in self._bad_songs: good_songs.append(song) if not good_songs: return None if random_: return random.choice(good_songs) else: return good_songs[0] @property def next_song(self): """next song for player, calculated based on playback_mode""" # 如果没有正在播放的歌曲,找列表里面第一首能播放的 if self.current_song is None: return self._get_good_song() if self.playback_mode == PlaybackMode.random: next_song = self._get_good_song(random_=True) else: current_index = self._songs.index(self.current_song) if current_index == len(self._songs) - 1: if self.playback_mode in (PlaybackMode.loop, PlaybackMode.one_loop): next_song = self._get_good_song() elif self.playback_mode == PlaybackMode.sequential: next_song = None else: next_song = self._get_good_song(base=current_index + 1, loop=False) return next_song @property def previous_song(self): """previous song for player to play NOTE: not the last played song """ if self.current_song is None: return self._get_good_song(base=-1, direction=-1) if self.playback_mode == PlaybackMode.random: previous_song = self._get_good_song(direction=-1) else: current_index = self._songs.index(self.current_song) previous_song = self._get_good_song(base=current_index - 1, direction=-1) return previous_song def next(self): if self.next_song is None: self.eof_reached.emit() else: self.current_song = self.next_song def previous(self): """return to the previous song in playlist""" self.current_song = self.previous_song @property def current_song(self) -> Optional[SongProtocol]: """Current song return None if there is no current song """ return self._current_song @current_song.setter def current_song(self, song: Optional[SongProtocol]): """设置当前歌曲,将歌曲加入到播放列表,并发出 song_changed 信号 .. note:: 该方法理论上只应该被 Player 对象调用。 if song has not valid media, we find a replacement in other providers """ if song is None: self.pure_set_current_song(None, None) return if self.mode is PlaylistMode.fm and song not in self._songs: self.mode = PlaylistMode.normal # FIXME(cosven): `current_song.setter` depends on app.task_mgr and app.library, # which make it hard to test. task_spec = self._app.task_mgr.get_or_create('set-current-song') task_spec.bind_coro(self.a_set_current_song(song)) async def a_set_current_song(self, song): song_str = f'{song.source}:{song.title_display} - {song.artists_name_display}' try: media = await self._prepare_media(song) except MediaNotFound: logger.info(f'{song_str} has no valid media, mark it as bad') self.mark_as_bad(song) # if mode is fm mode, do not find standby song, # just skip the song if self.mode is not PlaylistMode.fm: self._app.show_msg( f'{song_str} is invalid, try to find standby') logger.info(f'try to find standby for {song_str}') standby_candidates = await self._app.library.a_list_song_standby( song) if standby_candidates: standby = standby_candidates[0] self._app.show_msg( f'Song standby was found in {standby.source} ✅') # Insert the standby song after the song if song in self._songs and standby not in self._songs: index = self._songs.index(song) self._songs.insert(index + 1, standby) # NOTE: a_list_song_standby ensure that the song.url is not empty # FIXME: maybe a_list_song_standby should return media directly self.pure_set_current_song(standby, standby.url) else: self._app.show_msg('Song standby not found') self.pure_set_current_song(song, None) else: self.next() except ProviderIOError as e: # FIXME: This may cause infinite loop when the prepare media always fails logger.error(f'prepare media failed: {e}, try next song') self.pure_set_current_song(song, None) except: # noqa # When the exception is unknown, we mark the song as bad. logger.exception('prepare media failed due to unknown error, ' 'so we mark the song as a bad one') self.mark_as_bad(song) self.next() else: assert media, "media must not be empty" self.pure_set_current_song(song, media) def pure_set_current_song(self, song, media): if song is None: self._current_song = None else: # add it to playlist if song not in playlist if song in self._songs: self._current_song = song else: self.insert(song) self._current_song = song self.song_changed.emit(song) self.song_changed_v2.emit(song, media) async def _prepare_media(self, song): task_spec = self._app.task_mgr.get_or_create('prepare-media') if self.watch_mode is True: try: mv_media = await task_spec.bind_blocking_io( self._app.library.song_prepare_mv_media, song, self._app.config.VIDEO_SELECT_POLICY) except MediaNotFound: mv_media = None self._app.show_msg('No mv found') except Exception as e: # noqa mv_media = None self._app.show_msg(f'Prepare mv media failed: {e}') if mv_media: return mv_media return await task_spec.bind_blocking_io( self._app.library.song_prepare_media, song, self.audio_select_policy)
class App: """App 基类""" _instance = None # .. deprecated:: 3.8 # Use :class:`AppMode` instead. DaemonMode = AppMode.server.value GuiMode = AppMode.gui.value CliMode = AppMode.cli.value def __init__(self, args, config): self.mode = config.MODE # DEPRECATED: use app.config.MODE instead self.config = config self.args = args self.initialized = Signal() self.about_to_shutdown = Signal() self.plugin_mgr = PluginsManager(self) self.request = Request() # TODO: rename request to http self.version_mgr = VersionManager(self) self.task_mgr = TaskManager(self) # Library. self.library = Library(config.PROVIDERS_STANDBY) # TODO: initialization should be moved into initialize Resolver.library = self.library # Player. self.player = Player( audio_device=bytes(config.MPV_AUDIO_DEVICE, 'utf-8')) self.playlist = Playlist( self, audio_select_policy=config.AUDIO_SELECT_POLICY) self.live_lyric = LiveLyric(self) self.fm = FM(self) # TODO: initialization should be moved into initialize self.player.set_playlist(self.playlist) self.about_to_shutdown.connect(lambda _: self.dump_state(), weak=False) def initialize(self): self.player.position_changed.connect( self.live_lyric.on_position_changed) self.playlist.song_changed.connect(self.live_lyric.on_song_changed, aioqueue=True) self.plugin_mgr.scan() def run(self): pass @property def instance(self) -> Optional['App']: """App running instance. .. versionadded:: 3.8 """ return App._instance @property def has_server(self) -> bool: """ .. versionadded:: 3.8 """ return AppMode.server in AppMode(self.config.MODE) @property def has_gui(self) -> bool: """ .. versionadded:: 3.8 """ return AppMode.gui in AppMode(self.config.MODE) def show_msg(self, msg, *args, **kwargs): """在程序中显示消息,一般是用来显示程序当前状态""" # pylint: disable=no-self-use, unused-argument logger.info(msg) def get_listen_addr(self): return '0.0.0.0' if self.config.ALLOW_LAN_CONNECT else '127.0.0.1' def load_state(self): playlist = self.playlist player = self.player try: with open(STATE_FILE, 'r', encoding='utf-8') as f: state = json.load(f) except FileNotFoundError: pass except json.decoder.JSONDecodeError: logger.exception('invalid state file') else: player.volume = state['volume'] playlist.playback_mode = PlaybackMode(state['playback_mode']) songs = [] for song in state['playlist']: try: song = resolve(song) except ResolverNotFound: pass else: songs.append(song) playlist.set_models(songs) song = state['song'] def before_media_change(old_media, media): # When the song has no media or preparing media is failed, # the current_song is not the song we set. # # When user play a new media directly through player.play interface, # the old media is not None. if old_media is not None or playlist.current_song != song: player.media_about_to_changed.disconnect( before_media_change) player.set_play_range() if song is not None: try: song = resolve(state['song']) except ResolverNotFound: pass else: player.media_about_to_changed.connect(before_media_change, weak=False) player.pause() player.set_play_range(start=state['position']) playlist.set_current_song(song) def dump_state(self): logger.info("Dump app state") playlist = self.playlist player = self.player song = self.player.current_song if song is not None: song = reverse(song, as_line=True) # TODO: dump player.media state = { 'playback_mode': playlist.playback_mode.value, 'volume': player.volume, 'state': player.state.value, 'song': song, 'position': player.position, 'playlist': [reverse(song, as_line=True) for song in playlist.list()], } with open(STATE_FILE, 'w', encoding='utf-8') as f: json.dump(state, f) @contextmanager def create_action(self, s): # pylint: disable=no-self-use """根据操作描述生成 Action (alpha) 设计缘由:用户需要知道目前程序正在进行什么操作,进度怎么样, 结果是失败或者成功。这里将操作封装成 Action。 """ show_msg = self.show_msg class ActionError(Exception): pass class Action: def set_progress(self, value): value = int(value * 100) show_msg(s + f'...{value}%', timeout=-1) def failed(self, msg=''): raise ActionError(msg) show_msg(s + '...', timeout=-1) # doing try: yield Action() except ActionError as e: show_msg(s + f'...failed\t{str(e)}') except Exception as e: show_msg(s + f'...error\t{str(e)}') # error raise else: show_msg(s + '...done') # done def about_to_exit(self): logger.info('Do graceful shutdown') try: self.about_to_shutdown.emit(self) self.player.stop() self.exit_player() except: # noqa, pylint: disable=bare-except logger.exception("about-to-exit failed") logger.info('Ready for shutdown') def exit_player(self): self.player.shutdown() # this cause 'abort trap' on macOS def exit(self): self.about_to_exit()
class MpvPlayer(AbstractPlayer): """ player will always play playlist current song. player will listening to playlist ``song_changed`` signal and change the current playback. todo: make me singleton """ def __init__(self, _=None, audio_device=b'auto', winid=None, **kwargs): """ :param _: keep this arg to keep backward compatibility """ super().__init__(**kwargs) # https://github.com/cosven/FeelUOwn/issues/246 locale.setlocale(locale.LC_NUMERIC, 'C') mpvkwargs = {} if winid is not None: mpvkwargs['wid'] = winid self._version = _mpv_client_api_version() # old version libmpv can use opengl-cb if self._version < (1, 107): mpvkwargs['vo'] = 'opengl-cb' self.use_opengl_cb = True else: self.use_opengl_cb = False # set log_handler if you want to debug # mpvkwargs['log_handler'] = self.__log_handler # mpvkwargs['msg_level'] = 'all=v' # the default version of libmpv on Ubuntu 18.04 is (1, 25) self._mpv = MPV(ytdl=False, input_default_bindings=True, input_vo_keyboard=True, **mpvkwargs) _mpv_set_property_string(self._mpv.handle, b'audio-device', audio_device) # old version libmpv(for example: (1, 20)) should set option by using # _mpv_set_option_string, while newer version can use _mpv_set_property_string _mpv_set_option_string(self._mpv.handle, b'user-agent', b'Mozilla/5.0 (Windows NT 10.0; Win64; x64)') #: if video_format changes to None, there is no video available self.video_format_changed = Signal() self._mpv.observe_property( 'time-pos', lambda name, position: self._on_position_changed(position)) self._mpv.observe_property( 'duration', lambda name, duration: self._on_duration_changed(duration)) self._mpv.observe_property( 'video-format', lambda name, vformat: self._on_video_format_changed(vformat)) # self._mpv.register_event_callback(lambda event: self._on_event(event)) self._mpv._event_callbacks.append(self._on_event) logger.debug('Player initialize finished.') def shutdown(self): # The mpv has already been terminated. # The mpv can't terminate twice. if self._mpv.handle is not None: self._mpv.terminate() def play(self, media, video=True, metadata=None): if video is False: _mpv_set_property_string(self._mpv.handle, b'vid', b'no') else: _mpv_set_property_string(self._mpv.handle, b'vid', b'auto') self.media_about_to_changed.emit(self._current_media, media) if media is None: self._stop_mpv() else: logger.debug("Player will play: '%s'", media) if isinstance(media, Media): media = media else: # media is a url(string) media = Media(media) self._set_http_headers(media.http_headers) self._stop_mpv() if media.manifest is None: url = media.url # Clear playlist before play next song, # otherwise, mpv will seek to the last position and play. self._mpv.play(url) elif isinstance(media.manifest, VideoAudioManifest): video_url = media.manifest.video_url audio_url = media.manifest.audio_url def add_audio(): try: if self.current_media is media: self._mpv.audio_add(audio_url) self.resume() finally: self.media_loaded.disconnect(add_audio) if video is True: self._mpv.play(video_url) # It seems we can only add audio after the video is loaded. # TODO: add method connect_once for signal self.media_loaded.connect(add_audio, weak=False) else: self._mpv.play(audio_url) else: assert False, 'Unknown manifest' self._current_media = media self.media_changed.emit(media) if metadata is None: self._current_metadata = {} else: # The metadata may be set by manual or automatic metadata['__setby__'] = 'manual' self._current_metadata = metadata self.metadata_changed.emit(self.current_metadata) def set_play_range(self, start=None, end=None): if self._version >= (1, 28): start_default, end_default = 'none', 'none' else: start_default, end_default = '0%', '100%' start = str(start) if start is not None else start_default end = str(end) if end is not None else end_default _mpv_set_option_string(self._mpv.handle, b'start', bytes(start, 'utf-8')) _mpv_set_option_string(self._mpv.handle, b'end', bytes(end, 'utf-8')) def resume(self): self._mpv.pause = False self.state = State.playing def pause(self): self._mpv.pause = True self.state = State.paused def toggle(self): self._mpv.pause = not self._mpv.pause if self._mpv.pause: self.state = State.paused else: self.state = State.playing def stop(self): self._mpv.pause = True self.state = State.stopped self.play(None) logger.debug('Player stopped.') @property def position(self): return self._position @position.setter def position(self, position): if self._current_media: self._mpv.seek(position, reference='absolute') self._position = position self.seeked.emit(position) else: logger.warn("can't set position when current media is empty") @AbstractPlayer.volume.setter # type: ignore def volume(self, value): super(MpvPlayer, MpvPlayer).volume.__set__(self, value) self._mpv.volume = self.volume @property def video_format(self): return self._video_format @video_format.setter def video_format(self, vformat): self._video_format = vformat self.video_format_changed.emit(vformat) def _stop_mpv(self): # Remove current media. self._mpv.play("") # Clear the playlist so that no other media will be played. self._mpv.playlist_clear() def _on_position_changed(self, position): self._position = position self.position_changed.emit(position) def _on_duration_changed(self, duration): """listening to mpv duration change event""" logger.debug('Player receive duration changed signal') self.duration = duration def _on_video_format_changed(self, vformat): self.video_format = vformat def _on_event(self, event): event_id = event['event_id'] if event_id == MpvEventID.END_FILE: reason = event['event']['reason'] logger.debug('Current song finished. reason: %d' % reason) if self.state != State.stopped and reason != MpvEventEndFile.ABORTED: self.media_finished.emit() elif event_id == MpvEventID.FILE_LOADED: self.media_loaded.emit() elif event_id == MpvEventID.METADATA_UPDATE: metadata = dict(self._mpv.metadata or {}) # type: ignore logger.debug('metadata updated to %s', metadata) if self._current_metadata.get('__setby__') != 'manual': self._current_metadata['__setby__'] = 'automatic' mapping = Metadata({ MetadataFields.title: 'title', MetadataFields.album: 'album', MetadataFields.artists: 'artist' }) for src, tar in mapping.items(): if tar in metadata: value = metadata[tar] if src is MetadataFields.artists: value = [value] self._current_metadata[src] = value self.metadata_changed.emit(self.current_metadata) def _set_http_headers(self, http_headers): if http_headers: headers = [] for key, value in http_headers.items(): headers.append("{}: {}".format(key, value)) headers_text = ','.join(headers) headers_bytes = bytes(headers_text, 'utf-8') logger.info('play media with headers: %s', headers_text) _mpv_set_option_string(self._mpv.handle, b'http-header-fields', headers_bytes) else: _mpv_set_option_string(self._mpv.handle, b'http-header-fields', b'') def __log_handler(self, loglevel, component, message): print('[{}] {}: {}'.format(loglevel, component, message))
class App: """App 基类""" DaemonMode = 0x0001 # 开启 daemon GuiMode = 0x0010 # 显示 GUI CliMode = 0x0100 # 命令行模式 def __init__(self, config): self.mode = config.MODE # DEPRECATED: use app.config.MODE instead self.config = config self.initialized = Signal() self.about_to_shutdown = Signal() self.initialized.connect(lambda _: self.load_state(), weak=False) self.about_to_shutdown.connect(lambda _: self.dump_state(), weak=False) def show_msg(self, msg, *args, **kwargs): """在程序中显示消息,一般是用来显示程序当前状态""" # pylint: disable=no-self-use, unused-argument logger.info(msg) def get_listen_addr(self): return '0.0.0.0' if self.config.ALLOW_LAN_CONNECT else '127.0.0.1' def load_state(self): playlist = self.playlist player = self.player try: with open(STATE_FILE, 'r') as f: state = json.load(f) except FileNotFoundError: pass except json.decoder.JSONDecodeError: logger.exception('invalid state file') else: player.volume = state['volume'] playlist.playback_mode = PlaybackMode(state['playback_mode']) songs = [] for song in state['playlist']: try: song = resolve(song) except ResolverNotFound: pass else: songs.append(song) playlist.init_from(songs) if songs and self.mode & App.GuiMode: self.browser.goto(page='/player_playlist') song = state['song'] def before_media_change(old_media, media): if old_media is not None or playlist.current_song != song: player.media_about_to_changed.disconnect( before_media_change) player.set_play_range() player.resume() if song is not None: try: song = resolve(state['song']) except ResolverNotFound: pass else: player.media_about_to_changed.connect(before_media_change, weak=False) player.pause() player.set_play_range(start=state['position']) player.load_song(song) def dump_state(self): logger.info("Dump app state") playlist = self.playlist player = self.player song = self.player.current_song if song is not None: song = reverse(song, as_line=True) # TODO: dump player.media state = { 'playback_mode': playlist.playback_mode.value, 'volume': player.volume, 'state': player.state.value, 'song': song, 'position': player.position, 'playlist': [reverse(song, as_line=True) for song in playlist.list()], } with open(STATE_FILE, 'w') as f: json.dump(state, f) @contextmanager def create_action(self, s): # pylint: disable=no-self-use """根据操作描述生成 Action (alpha) 设计缘由:用户需要知道目前程序正在进行什么操作,进度怎么样, 结果是失败或者成功。这里将操作封装成 Action。 """ show_msg = self.show_msg class ActionError(Exception): pass class Action: def set_progress(self, value): value = int(value * 100) show_msg(s + '...{}%'.format(value), timeout=-1) def failed(self, msg=''): raise ActionError(msg) show_msg(s + '...', timeout=-1) # doing try: yield Action() except ActionError as e: show_msg(s + '...failed\t{}'.format(str(e))) except Exception as e: show_msg(s + '...error\t{}'.format(str(e))) # error raise else: show_msg(s + '...done') # done def about_to_exit(self): try: logger.info('Do graceful shutdown') self.about_to_shutdown.emit(self) Signal.teardown_aio_support() self.player.stop() self.exit_player() except: # noqa logger.exception("about-to-exit failed") def exit_player(self): self.player.shutdown() # this cause 'abort trap' on macOS def exit(self): self.about_to_exit() loop = asyncio.get_event_loop() loop.stop() loop.close()
class DownloadManager: def __init__(self, app, config): """ :type app: feeluown.app.App """ self._tasks = [] self._task_queue = asyncio.Queue() #: emit List[DownloadTask] self.tasks_changed = Signal() self.download_finished = Signal() self.downloader: Downloader = AioRequestsDownloader() self._path = config.DOWNLOAD_DIR or DEFAULT_DOWNLOAD_DIR def initialize(self): aio.create_task(self.worker()) def list_tasks(self) -> List[DownloadTask]: return self._tasks async def get(self, url, filename, headers=None, cookies=None): """download and save a file :param url: file url :param filename: target file name """ # check if there exists same task for task in self.list_tasks(): if task.filename == filename: logger.warning( f'task: {filename} has already been put into queue') return filepath = self._getpath(filename) dirpath = os.path.dirname(filepath) if not os.path.exists(dirpath): os.makedirs(dirpath) task = DownloadTask(url, filename, self.downloader) self._tasks.append(task) await self._task_queue.put(task) self.tasks_changed.emit(self.list_tasks()) logger.info(f'task: {filename} has been put into queue') return filepath async def worker(self): while True: task = await self._task_queue.get() await self.run_task(task) # task done and remove task self._task_queue.task_done() self._tasks.remove(task) self.tasks_changed.emit(self.list_tasks()) path = self._getpath(task.filename) logger.info(f'content has been saved into {path}') self.download_finished.emit(task.filename, True) async def run_task(self, task): task.status = DownloadStatus.running filename = task.filename downloader = task.downloader filepath = self._getpath(filename) try: ok = await downloader.run(task.url, filepath) except asyncio.CancelledError: task.status = DownloadStatus.failed except Exception: logger.exception('download failed') task.status = DownloadStatus.failed else: if ok: task.status = DownloadStatus.ok else: task.status = DownloadStatus.failed # clean up the temp file if needed if task.status is DownloadStatus.failed: downloader.clean(filepath) self.download_finished.emit(filename, False) def is_file_downloaded(self, filename): return os.path.exists(self._getpath(filename)) def _getpath(self, filename): return os.path.join(self._path, filename)
class Playlist(_Playlist): def __init__(self, app, *args, **kwargs): super().__init__(*args, **kwargs) self._app = app #: mainthread asyncio loop ref # We know that feeluown is a asyncio-app, and we can assume # that the playlist is inited in main thread. self._loop = asyncio.get_event_loop() #: find-song-standby task self._task = None #: init playlist mode normal self._mode = PlaylistMode.normal #: playlist eof signal # playlist have no enough songs self.eof_reached = Signal() #: playlist mode changed signal self.mode_changed = Signal() def add(self, song): """add song to playlist Theoretically, when playlist is in FM mode, we should not change songs list manually(without ``fm_add`` method). However, when it happens, we exit FM mode. """ if self._mode is PlaylistMode.fm: self.mode = PlaylistMode.normal super().add(song) def insert(self, song): """insert song into playlist""" if self._mode is PlaylistMode.fm: self.mode = PlaylistMode.normal super().insert(song) def fm_add(self, song): super().add(song) @_Playlist.current_song.setter def current_song(self, song): """if song has not valid media, we find a replacement in other providers""" if song is None: self._set_current_song(None, None) return if self.mode is PlaylistMode.fm and song not in self._songs: self.mode = PlaylistMode.normal def cb(future): try: future.result() except: # noqa logger.exception('async set current song failed') task_spec = self._app.task_mgr.get_or_create('set-current-song') task = task_spec.bind_coro(self.a_set_current_song(song)) task.add_done_callback(cb) def init_from(self, songs): # THINKING: maybe we should rename this method or maybe we should # change mode on application level # # We change playlistmode here because the `player.play_all` call this # method. We should check if we need to exit fm mode in `play_xxx`. # Currently, we have two play_xxx API: play_all and play_song. # 1. play_all -> init_from # 2. play_song -> current_song.setter if self.mode is PlaylistMode.fm: self.mode = PlaylistMode.normal super().init_from(songs) async def a_set_current_song(self, song): task_spec = self._app.task_mgr.get_or_create('prepare-media') future = task_spec.bind_blocking_io(self.prepare_media, song) try: media = await future except MediaNotFound: media = None except: # noqa logger.exception('prepare media failed') self.next() return if media is not None: self._set_current_song(song, media) return logger.info('song:{} media is None, mark as bad') self.mark_as_bad(song) # if mode is fm mode, do not find standby song, # just skip the song if self.mode is not PlaylistMode.fm: song_str = f'song:{song.source}:{song.title_display}' self._app.show_msg(f'{song_str} is invalid, try to find standby') logger.info(f'try to find standby for {song_str}') songs = await self._app.library.a_list_song_standby(song) if songs: final_song = songs[0] logger.info('find song standby success: %s', final_song) # NOTE: a_list_song_standby ensure that the song.url is not empty # FIXME: maybe a_list_song_standby should return media directly self._set_current_song(final_song, final_song.url) else: logger.info('find song standby failed: not found') final_song = song self._set_current_song(final_song, None) else: self.next() @_Playlist.playback_mode.setter def playback_mode(self, playback_mode): if self._mode is PlaylistMode.fm: if playback_mode is not PlaybackMode.sequential: logger.warning("can't set playback mode to others in fm mode") else: _Playlist.playback_mode.__set__(self, PlaybackMode.sequential) else: _Playlist.playback_mode.__set__(self, playback_mode) @property def mode(self): return self._mode @mode.setter def mode(self, mode): """set playlist mode""" if self._mode is not mode: if mode is PlaylistMode.fm: self.playback_mode = PlaybackMode.sequential self.clear() # we should change _mode at the very end self._mode = mode self.mode_changed.emit(mode) logger.info('playlist mode changed to %s', mode) def next(self): if self.next_song is None: self.eof_reached.emit() else: super().next() def prepare_media(self, song): """prepare media data """ return self._app.library.song_prepare_media(song, self.audio_select_policy)
class Library: """音乐库,管理资源提供方以及资源""" def __init__(self, providers_standby=None): """ :type app: feeluown.app.App """ self._providers_standby = providers_standby self._providers = set() self.provider_added = Signal() # emit(AbstractProvider) self.provider_removed = Signal() # emit(AbstractProvider) def register(self, provider): """register 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.library.ProviderAlreadyExists """ if not isinstance(provider, AbstractProvider): raise ValueError('invalid provider instance') for _provider in self._providers: if _provider.identifier == provider.identifier: raise ProviderAlreadyExists self._providers.add(provider) self.provider_added.emit(provider) def deregister(self, provider): """deregister provider""" try: self._providers.remove(provider) except ValueError: raise ProviderNotFound from None else: self.provider_removed.emit(provider) def get(self, identifier): """通过资源提供方唯一标识获取提供方实例""" for provider in self._providers: if provider.identifier == identifier: return provider return None def list(self): """列出所有资源提供方""" return list(self._providers) def _filter(self, identifier_in=None): if identifier_in is None: return iter(self._providers) return filter(lambda p: p.identifier in identifier_in, self.list()) def search(self, keyword, type_in=None, source_in=None, **kwargs): """search song/artist/album/playlist by keyword please use a_search method if you can. :param keyword: search keyword :param type_in: search type :param source_in: None or provider identifier list - TODO: support search with filters(by artist or by source) """ type_in = SearchType.batch_parse(type_in) if type_in else [ SearchType.so ] for provider in self._filter(identifier_in=source_in): for type_ in type_in: try: result = provider.search(keyword=keyword, type_=type_, **kwargs) except Exception: # pylint: disable=broad-except logger.exception('Search %s in %s failed.', keyword, provider) else: if result is not None: yield result async def a_search(self, keyword, source_in=None, timeout=None, type_in=None, **kwargs): """async version of search TODO: add Happy Eyeballs requesting strategy if needed """ type_in = SearchType.batch_parse(type_in) if type_in else [ SearchType.so ] fs = [] # future list for provider in self._filter(identifier_in=source_in): for type_ in type_in: future = aio.run_in_executor( None, partial(provider.search, keyword, type_=type_)) fs.append(future) for future in aio.as_completed(fs, timeout=timeout): try: result = await future except: # noqa logger.exception('search task failed') continue else: 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 # # methods for v2 # # provider common def get_or_raise(self, identifier) -> ProviderV2: """ :raises ProviderNotFound: """ provider = self.get(identifier) if provider is None: raise ProviderNotFound(f'provider {identifier} not found') return provider def check_flags(self, source: str, model_type: ModelType, flags: PF) -> bool: """Check if a provider satisfies the specific ability for a model type .. note:: Currently, we use ProviderFlags to define which ability a provider has. In the future, we may use typing.Protocol. So you should use :meth:`check_flags` method to check ability instead of compare provider flags directly. """ provider = self.get(source) if provider is None: return False if isinstance(provider, ProviderV2): return provider.check_flags(model_type, flags) return False def check_flags_by_model(self, model: ModelProtocol, flags: PF) -> bool: """Alias for check_flags""" return self.check_flags(model.source, ModelType(model.meta.model_type), flags) # --------------------------- # Methods for backward compat # --------------------------- def cast_model_to_v1(self, model): """Cast a v1/v2 model to v1 During the model migration from v1 to v2, v2 may lack some ability. Cast the model to v1 to acquire such ability. """ 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) kv = {} for field in ModelCls.meta.fields_display: kv[field] = getattr(model, field) return ModelCls.create_by_display(identifier=model.identifier, **kv) # ----- # Songs # ----- def song_upgrade(self, song: BriefSongProtocol) -> SongProtocol: if song.meta.flags & MF.v2: if MF.normal in song.meta.flags: upgraded_song = cast(SongProtocol, song) else: provider = self.get_or_raise(song.source) if self.check_flags_by_model(song, PF.get): upgraded_song = provider.song_get(song.identifier) else: raise NotSupported( "provider has not flag 'get' for 'song'") else: fields = [ f for f in list(SongModel.__fields__) if f not in list(BaseModel.__fields__) ] for field in fields: getattr(song, field) upgraded_song = cast(SongProtocol, song) return upgraded_song def song_list_similar(self, song: BriefSongProtocol) -> List[BriefSongProtocol]: provider = self.get_or_raise(song.source) return provider.song_list_similar(song) def song_list_hot_comments(self, song: BriefSongProtocol): provider = self.get_or_raise(song.source) return provider.song_list_hot_comments(song) def song_prepare_media(self, song: BriefSongProtocol, policy) -> Media: provider = self.get(song.source) if provider is None: raise MediaNotFound(f'provider:{song.source} not found') if song.meta.flags & MF.v2: # provider MUST has multi_quality flag for song assert self.check_flags_by_model(song, PF.multi_quality) media, _ = provider.song_select_media(song, policy) else: if song.meta.support_multi_quality: media, _ = song.select_media(policy) # type: ignore else: url = song.url # type: ignore if url: media = Media(url) else: raise MediaNotFound if not media: raise MediaNotFound return media def song_prepare_mv_media(self, song: BriefSongProtocol, policy) -> Media: """ .. versionadded:: 3.7.5 """ provider = self.get(song.source) if provider is None: raise MediaNotFound(f'provider:{song.source} not found') song_v1 = self.cast_model_to_v1(song) mv = song_v1.mv if mv.meta.support_multi_quality: media, _ = mv.select_media(policy) else: media = mv.media if media: media = Media(media) else: media = None if not media: raise MediaNotFound return media def song_get_lyric(self, song: BriefSongModel): pass def song_get_mv(self, song: BriefSongModel): pass # -------- # Provider # -------- def provider_has_current_user(self, source: str) -> bool: """Check if a provider has a logged in user No IO operation is triggered. .. versionadded:: 3.7.6 """ provider = self.get_or_raise(source) if self.check_flags(source, ModelType.none, PF.current_user): return provider.has_current_user() try: user_v1 = getattr(provider, '_user') except AttributeError: logger.warn( "We can't determine if the provider has a current user") return False else: return user_v1 is not None def provider_get_current_user(self, source: str) -> UserProtocol: """Get provider current logged in user :raises NotSupported: :raises ProviderNotFound: :raises NoUserLoggedIn: .. versionadded:: 3.7.6 """ provider = self.get_or_raise(source) if self.check_flags(source, ModelType.none, PF.current_user): return provider.get_current_user() user_v1 = getattr(provider, '_user', None) if user_v1 is None: raise NoUserLoggedIn return UserModel(identifier=user_v1.identifier, source=source, name=user_v1.name_display, avatar_url='')
class MpvPlayer(AbstractPlayer): """ player will always play playlist current song. player will listening to playlist ``song_changed`` signal and change the current playback. todo: make me singleton """ def __init__(self, playlist, audio_device=b'auto', winid=None, **kwargs): super().__init__(playlist=playlist, **kwargs) # https://github.com/cosven/FeelUOwn/issues/246 locale.setlocale(locale.LC_NUMERIC, 'C') mpvkwargs = {} if winid is not None: mpvkwargs['wid'] = winid self._version = _mpv_client_api_version() # old version libmpv can use opengl-cb if self._version < (1, 107): mpvkwargs['vo'] = 'opengl-cb' self.use_opengl_cb = True else: self.use_opengl_cb = False # set log_handler if you want to debug # mpvkwargs['log_handler'] = self.__log_handler # mpvkwargs['msg_level'] = 'all=v' # the default version of libmpv on Ubuntu 18.04 is (1, 25) self._mpv = MPV(ytdl=False, input_default_bindings=True, input_vo_keyboard=True, **mpvkwargs) _mpv_set_property_string(self._mpv.handle, b'audio-device', audio_device) # old version libmpv(for example: (1, 20)) should set option by using # _mpv_set_option_string, while newer version can use _mpv_set_property_string _mpv_set_option_string(self._mpv.handle, b'user-agent', b'Mozilla/5.0 (Windows NT 10.0; Win64; x64)') #: if video_format changes to None, there is no video available self.video_format_changed = Signal() self._mpv.observe_property( 'time-pos', lambda name, position: self._on_position_changed(position)) self._mpv.observe_property( 'duration', lambda name, duration: self._on_duration_changed(duration)) self._mpv.observe_property( 'video-format', lambda name, vformat: self._on_video_format_changed(vformat)) # self._mpv.register_event_callback(lambda event: self._on_event(event)) self._mpv._event_callbacks.append(self._on_event) logger.debug('Player initialize finished.') def shutdown(self): self._mpv.terminate() def play(self, media, video=True): # if not (self._app.mode & self._app.GuiMode): # video = False logger.debug("Player will play: '%s'", media) if isinstance(media, Media): media = media else: # media is a url media = Media(media) self._set_http_headers(media.http_headers) url = media.url # Clear playlist before play next song, # otherwise, mpv will seek to the last position and play. self.media_about_to_changed.emit(self._current_media, media) self._mpv.playlist_clear() self._mpv.play(url) self._current_media = media self.media_changed.emit(media) def set_play_range(self, start=None, end=None): if self._version >= (1, 28): start_default, end_default = 'none', 'none' else: start_default, end_default = '0%', '100%' start = str(start) if start is not None else start_default end = str(end) if end is not None else end_default _mpv_set_option_string(self._mpv.handle, b'start', bytes(start, 'utf-8')) _mpv_set_option_string(self._mpv.handle, b'end', bytes(end, 'utf-8')) def resume(self): self._mpv.pause = False self.state = State.playing def pause(self): self._mpv.pause = True self.state = State.paused def toggle(self): self._mpv.pause = not self._mpv.pause if self._mpv.pause: self.state = State.paused else: self.state = State.playing def stop(self): self._mpv.pause = True self.state = State.stopped self._current_media = None self._mpv.playlist_clear() logger.info('Player stopped.') @property def position(self): return self._position @position.setter def position(self, position): if self._current_media: self._mpv.seek(position, reference='absolute') self._position = position else: logger.warn("can't set position when current media is empty") @AbstractPlayer.volume.setter # type: ignore def volume(self, value): super(MpvPlayer, MpvPlayer).volume.__set__(self, value) self._mpv.volume = self.volume @property def video_format(self): return self._video_format @video_format.setter def video_format(self, vformat): self._video_format = vformat self.video_format_changed.emit(vformat) def _on_position_changed(self, position): self._position = position self.position_changed.emit(position) def _on_duration_changed(self, duration): """listening to mpv duration change event""" logger.debug('Player receive duration changed signal') self.duration = duration def _on_video_format_changed(self, vformat): self.video_format = vformat def _on_event(self, event): event_id = event['event_id'] if event_id == MpvEventID.END_FILE: reason = event['event']['reason'] logger.debug('Current song finished. reason: %d' % reason) if self.state != State.stopped and reason != MpvEventEndFile.ABORTED: self.media_finished.emit() elif event_id == MpvEventID.FILE_LOADED: self.media_loaded.emit() def _set_http_headers(self, http_headers): if http_headers: headers = [] for key, value in http_headers.items(): headers.append("{}: {}".format(key, value)) headers_text = ','.join(headers) headers_bytes = bytes(headers_text, 'utf-8') logger.info('play media with headers: %s', headers_text) _mpv_set_option_string(self._mpv.handle, b'http-header-fields', headers_bytes) else: _mpv_set_option_string(self._mpv.handle, b'http-header-fields', b'') def __log_handler(self, loglevel, component, message): print('[{}] {}: {}'.format(loglevel, component, message))
class SongsTableView(ItemViewNoScrollMixin, QTableView): show_artist_needed = pyqtSignal([object]) show_album_needed = pyqtSignal([object]) play_song_needed = pyqtSignal([object]) add_to_playlist_needed = pyqtSignal(list) def __init__(self, parent=None): super().__init__(parent) QTableView.__init__(self, parent) # override ItemViewNoScrollMixin variables self._least_row_count = 6 self._row_height = 40 # slot functions self.remove_song_func = None self.delegate = SongsTableDelegate(self) self.setItemDelegate(self.delegate) self.about_to_show_menu = Signal() self._setup_ui() def _setup_ui(self): self.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeToContents) # FIXME: PyQt5 seg fault # self.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setFrameShape(QFrame.NoFrame) self.horizontalHeader().setStretchLastSection(True) self.verticalHeader().hide() self.horizontalHeader().hide() self.verticalHeader().setDefaultSectionSize(self._row_height) self.setWordWrap(False) self.setTextElideMode(Qt.ElideRight) self.setMouseTracking(True) self.setEditTriggers(QAbstractItemView.SelectedClicked) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setShowGrid(False) self.setDragEnabled(True) self.setDragDropMode(QAbstractItemView.DragOnly) def show_artists_by_index(self, index): self.edit(index) def contextMenuEvent(self, event): indexes = self.selectionModel().selectedIndexes() if len(indexes) <= 0: return menu = QMenu() # add to playlist action add_to_playlist_action = QAction('添加到播放队列', menu) add_to_playlist_action.triggered.connect( lambda: self._add_to_playlist(indexes)) menu.addAction(add_to_playlist_action) # remove song action if self.remove_song_func is not None: remove_song_action = QAction('移除歌曲', menu) remove_song_action.triggered.connect( lambda: self._remove_by_indexes(indexes)) menu.addSeparator() menu.addAction(remove_song_action) model = self.model() models = [model.data(index, Qt.UserRole) for index in indexes] def add_action(text, callback): action = QAction(text, menu) menu.addSeparator() menu.addAction(action) action.triggered.connect(lambda: callback(models)) # .. versionadded: v3.7 # The context key *models* self.about_to_show_menu.emit({ 'add_action': add_action, 'models': models }) menu.exec(event.globalPos()) def _add_to_playlist(self, indexes): model = self.model() songs = [] for index in indexes: song = model.data(index, Qt.UserRole) songs.append(song) self.add_to_playlist_needed.emit(songs) def _remove_by_indexes(self, indexes): model = self.model() source_model = model.sourceModel() distinct_rows = set() for index in indexes: row = index.row() if row not in distinct_rows: song = model.data(index, Qt.UserRole) self.remove_song_func(song) distinct_rows.add(row) source_model.removeRows(indexes[0].row(), len(distinct_rows)) def mouseReleaseEvent(self, e): if e.button() in (Qt.BackButton, Qt.ForwardButton): e.ignore() else: super().mouseReleaseEvent(e)
class SongsTableView(ItemViewNoScrollMixin, QTableView): show_artist_needed = pyqtSignal([object]) show_album_needed = pyqtSignal([object]) play_song_needed = pyqtSignal([object]) add_to_playlist_needed = pyqtSignal(list) def __init__(self, parent=None): super().__init__(parent) QTableView.__init__(self, parent) # override ItemViewNoScrollMixin variables self._least_row_count = 6 self._row_height = 40 # slot functions self.remove_song_func = None self.delegate = SongsTableDelegate(self) self.setItemDelegate(self.delegate) self.activated.connect(self._on_activated) self.about_to_show_menu = Signal() self._setup_ui() def _setup_ui(self): self.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeToContents) # FIXME: PyQt5 seg fault # self.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setFrameShape(QFrame.NoFrame) self.horizontalHeader().setStretchLastSection(True) self.verticalHeader().hide() self.horizontalHeader().hide() self.verticalHeader().setDefaultSectionSize(self._row_height) self.setWordWrap(False) self.setTextElideMode(Qt.ElideRight) self.setMouseTracking(True) self.setEditTriggers(QAbstractItemView.SelectedClicked) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setShowGrid(False) self.setDragEnabled(True) self.setDragDropMode(QAbstractItemView.DragOnly) def _on_activated(self, index): try: if index.column() == Column.song: song = index.data(Qt.UserRole) self.play_song_needed.emit(song) elif index.column() == Column.artist: song = index.data(Qt.UserRole) artists = song.artists if artists is not None: if len(artists) > 1: self.edit(index) else: self.show_artist_needed.emit(artists[0]) elif index.column() == Column.album: song = index.data(Qt.UserRole) album = song.album self.show_album_needed.emit(album) except (ProviderIOError, Exception): # FIXME: we should only catch ProviderIOError here, # but currently, some plugins such fuo-qqmusic may raise # requests.RequestException logger.exception('fetch song.album failed') # FIXME: 在点击之后,音乐数据可能会有更新,理应触发界面更新 # 测试 dataChanged 似乎不能按照预期工作 model = self.model() topleft = model.index(index.row(), 0) bottomright = model.index(index.row(), 4) model.dataChanged.emit(topleft, bottomright, []) def contextMenuEvent(self, event): indexes = self.selectionModel().selectedIndexes() if len(indexes) <= 0: return menu = QMenu() # add to playlist action add_to_playlist_action = QAction('添加到播放队列', menu) add_to_playlist_action.triggered.connect( lambda: self._add_to_playlist(indexes)) menu.addAction(add_to_playlist_action) # remove song action if self.remove_song_func is not None: remove_song_action = QAction('移除歌曲', menu) remove_song_action.triggered.connect( lambda: self._remove_by_indexes(indexes)) menu.addSeparator() menu.addAction(remove_song_action) def add_action(text, callback): action = QAction(text, menu) menu.addSeparator() menu.addAction(action) model = self.model() action.triggered.connect(lambda: callback( [model.data(index, Qt.UserRole) for index in indexes])) self.about_to_show_menu.emit({'add_action': add_action}) menu.exec(event.globalPos()) def _add_to_playlist(self, indexes): model = self.model() songs = [] for index in indexes: song = model.data(index, Qt.UserRole) songs.append(song) self.add_to_playlist_needed.emit(songs) def _remove_by_indexes(self, indexes): model = self.model() source_model = model.sourceModel() distinct_rows = set() for index in indexes: row = index.row() if row not in distinct_rows: song = model.data(index, Qt.UserRole) self.remove_song_func(song) distinct_rows.add(row) source_model.removeRows(indexes[0].row(), len(distinct_rows)) def mouseReleaseEvent(self, e): if e.button() in (Qt.BackButton, Qt.ForwardButton): e.ignore() else: super().mouseReleaseEvent(e)