예제 #1
0
 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')
예제 #2
0
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
예제 #3
0
 def test_disconnect(self):
     s = Signal()
     s.connect(f)
     s.disconnect(f)
     s.emit(1, 'hello')
예제 #4
0
 def test_connect2(self):
     s = Signal()
     s.connect(f)
     s.emit(1, 'hello')
     s.emit(1, 'hello')
예제 #5
0
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
예제 #6
0
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
예제 #7
0
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)
예제 #8
0
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='')
예제 #9
0
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()
예제 #10
0
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)
예제 #11
0
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)
예제 #12
0
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 = ''
예제 #13
0
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)
예제 #14
0
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()
예제 #15
0
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))
예제 #16
0
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()
예제 #17
0
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)
예제 #18
0
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)
예제 #19
0
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='')
예제 #20
0
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))
예제 #21
0
파일: songs.py 프로젝트: QLjust/FeelUOwn
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)
예제 #22
0
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)