From 2b62a71375f1e4c3d7292554fdc46cd386365bb8 Mon Sep 17 00:00:00 2001 From: Shaowen Yin Date: Wed, 8 Jan 2025 02:25:14 +0800 Subject: [PATCH] =?UTF-8?q?[feat](player)=20add=20new=20feature=20?= =?UTF-8?q?=E2=80=9C=E8=87=AA=E5=8A=A8=E7=BB=AD=E6=AD=8C=E2=80=9D=20and=20?= =?UTF-8?q?enhance=20"=E6=AD=8C=E6=9B=B2=E7=94=B5=E5=8F=B0"=20(#894)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [x] add new feature '自动续歌' - [x] mark a song as 'dislike' when fm mode is enabled - [x] fix a normal(duplicate) model is added when user plays a brief song model --- .github/workflows/build.yml | 2 +- feeluown/gui/components/player_playlist.py | 15 +- feeluown/gui/uimain/playlist_overlay.py | 28 ++- feeluown/player/__init__.py | 3 +- feeluown/player/fm.py | 25 +-- feeluown/player/playlist.py | 204 +++++++++++++++------ feeluown/player/radio.py | 60 ++++-- tests/player/test_playlist.py | 52 ++++-- 8 files changed, 280 insertions(+), 109 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 20a6d34586..f4a9079ef5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: if: startsWith(matrix.os, 'ubuntu') run: | sudo apt-get update - sudo apt-get -y install libmpv1 + sudo apt-get -y install libmpv2 - name: Install macOS Dependencies if: startsWith(matrix.os, 'macos') diff --git a/feeluown/gui/components/player_playlist.py b/feeluown/gui/components/player_playlist.py index 90b8fa0439..d4f83dd9a0 100644 --- a/feeluown/gui/components/player_playlist.py +++ b/feeluown/gui/components/player_playlist.py @@ -1,6 +1,9 @@ +from typing import TYPE_CHECKING + from PyQt5.QtCore import Qt, QModelIndex, QItemSelectionModel from PyQt5.QtWidgets import QMenu, QAbstractItemView +from feeluown.player import PlaylistMode from feeluown.gui.components import SongMenuInitializer from feeluown.gui.helpers import fetch_cover_wrapper from feeluown.gui.widgets.song_minicard_list import ( @@ -10,6 +13,10 @@ from feeluown.utils.reader import create_reader +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp + + class PlayerPlaylistModel(SongMiniCardListModel): """ this is a singleton class (ensured by PlayerPlaylistView) @@ -51,7 +58,7 @@ class PlayerPlaylistView(SongMiniCardListView): _model = None - def __init__(self, app, *args, **kwargs): + def __init__(self, app: 'GuiApp', *args, **kwargs): super().__init__(*args, **kwargs) self._app = app @@ -70,7 +77,11 @@ def contextMenuEvent(self, e): songs = [index.data(Qt.UserRole)[0] for index in indexes] menu = QMenu() - action = menu.addAction('从播放队列中移除') + if self._app.playlist.mode is PlaylistMode.fm: + btn_text = '不想听' + else: + btn_text = '从播放队列中移除' + action = menu.addAction(btn_text) action.triggered.connect(lambda: self._remove_songs(songs)) if len(songs) == 1: menu.addSeparator() diff --git a/feeluown/gui/uimain/playlist_overlay.py b/feeluown/gui/uimain/playlist_overlay.py index 128ff54fee..b7ac7fffa0 100644 --- a/feeluown/gui/uimain/playlist_overlay.py +++ b/feeluown/gui/uimain/playlist_overlay.py @@ -7,7 +7,7 @@ QColor, QLinearGradient, QPalette, QPainter, ) -from feeluown.player import PlaybackMode +from feeluown.player import PlaybackMode, SongsRadio from feeluown.gui.helpers import fetch_cover_wrapper, esc_hide_widget from feeluown.gui.components.player_playlist import PlayerPlaylistView from feeluown.gui.widgets.textbtn import TextButton @@ -45,10 +45,14 @@ def __init__(self, app, *args, **kwargs): self._clear_playlist_btn = TextButton('清空播放队列') self._playback_mode_switch = PlaybackModeSwitch(app) self._goto_current_song_btn = TextButton('跳转到当前歌曲') + self._songs_radio_btn = TextButton('自动续歌') # Please update the list when you add new buttons. - self._btns = [self._clear_playlist_btn, - self._playback_mode_switch, - self._goto_current_song_btn] + self._btns = [ + self._clear_playlist_btn, + self._playback_mode_switch, + self._goto_current_song_btn, + self._songs_radio_btn, + ] self._stacked_layout = QStackedLayout() self._shadow_width = 15 self._view_options = dict(row_height=60, no_scroll_v=False) @@ -60,6 +64,7 @@ def __init__(self, app, *args, **kwargs): self._clear_playlist_btn.clicked.connect(self._app.playlist.clear) self._goto_current_song_btn.clicked.connect(self.goto_current_song) + self._songs_radio_btn.clicked.connect(self.enter_songs_radio) esc_hide_widget(self) q_app = QApplication.instance() assert q_app is not None # make type checker happy. @@ -72,22 +77,28 @@ def __init__(self, app, *args, **kwargs): def setup_ui(self): self._layout = QVBoxLayout(self) self._btn_layout = QHBoxLayout() + self._btn_layout2 = QHBoxLayout() self._layout.setContentsMargins(self._shadow_width, 0, 0, 0) self._layout.setSpacing(0) self._btn_layout.setContentsMargins(7, 7, 7, 7) self._btn_layout.setSpacing(7) + self._btn_layout2.setContentsMargins(7, 0, 7, 7) + self._btn_layout2.setSpacing(7) self._tabbar.setDocumentMode(True) self._tabbar.addTab('播放列表') self._tabbar.addTab('最近播放') self._layout.addWidget(self._tabbar) self._layout.addLayout(self._btn_layout) + self._layout.addLayout(self._btn_layout2) self._layout.addLayout(self._stacked_layout) self._btn_layout.addWidget(self._clear_playlist_btn) self._btn_layout.addWidget(self._playback_mode_switch) self._btn_layout.addWidget(self._goto_current_song_btn) + self._btn_layout2.addWidget(self._songs_radio_btn) self._btn_layout.addStretch(0) + self._btn_layout2.addStretch(0) def on_focus_changed(self, _, new): """ @@ -105,6 +116,15 @@ def goto_current_song(self): assert isinstance(view, PlayerPlaylistView) view.scroll_to_current_song() + def enter_songs_radio(self): + songs = self._app.playlist.list() + if not songs: + self._app.show_msg('播放队列为空,不能激活“自动续歌”功能') + else: + radio = SongsRadio(self._app, songs) + self._app.fm.activate(radio.fetch_songs_func, reset=False) + self._app.show_msg('“自动续歌”功能已激活') + def show_tab(self, index): if not self.isVisible(): return diff --git a/feeluown/player/__init__.py b/feeluown/player/__init__.py index b3e51d3b1c..90ed4a4b45 100644 --- a/feeluown/player/__init__.py +++ b/feeluown/player/__init__.py @@ -6,7 +6,7 @@ from .playlist import PlaylistMode, Playlist from .metadata_assembler import MetadataAssembler from .fm import FM -from .radio import SongRadio +from .radio import SongRadio, SongsRadio from .lyric import LiveLyric, parse_lyric_text, Line as LyricLine, Lyric from .recently_played import RecentlyPlayed from .delegate import PlayerPositionDelegate @@ -22,6 +22,7 @@ 'FM', 'PlaylistMode', 'SongRadio', + 'SongsRadio', 'Player', 'Playlist', diff --git a/feeluown/player/fm.py b/feeluown/player/fm.py index d7b7239bf8..5f888e8e38 100644 --- a/feeluown/player/fm.py +++ b/feeluown/player/fm.py @@ -1,10 +1,14 @@ +from typing import TYPE_CHECKING + import asyncio import logging -from collections import deque from feeluown.excs import ProviderIOError from feeluown.player import PlaylistMode +if TYPE_CHECKING: + from feeluown.app import App + logger = logging.getLogger(__name__) @@ -23,18 +27,16 @@ class FM: maybe a bit confusing. """ - def __init__(self, app): + def __init__(self, app: 'App'): """ :type app: feeluown.app.App """ self._app = app - # store songs that are going to be added to playlist - self._queue = deque() self._activated = False self._is_fetching_songs = False self._fetch_songs_task_name = 'fm-fetch-songs' - self._fetch_songs_func = None + self._fetch_songs_func = None # fn(number_to_fetch) self._minimum_per_fetch = 3 self._app.playlist.mode_changed.connect(self._on_playlist_mode_changed) @@ -78,10 +80,6 @@ def is_active(self): return self._app.playlist.mode is PlaylistMode.fm def _on_playlist_eof_reached(self): - if self._queue: - self._feed_playlist() - return - if self._is_fetching_songs: return @@ -102,9 +100,8 @@ def _on_playlist_fm_mode_exited(self): self._fetch_songs_func = None logger.info('fm mode deactivated') - def _feed_playlist(self): - while self._queue: - song = self._queue.popleft() + def _feed_playlist(self, songs): + for song in songs: self._app.playlist.fm_add(song) self._app.playlist.next() @@ -120,8 +117,6 @@ def _on_songs_fetched(self, future): logger.info('No enough songs, exit fm mode now') self.deactivate() else: - for song in songs: - self._queue.append(song) - self._feed_playlist() + self._feed_playlist(songs) finally: self._is_fetching_songs = False diff --git a/feeluown/player/playlist.py b/feeluown/player/playlist.py index f03e0ae53b..6665ab459b 100644 --- a/feeluown/player/playlist.py +++ b/feeluown/player/playlist.py @@ -4,6 +4,7 @@ import random from enum import IntEnum, Enum from typing import Optional, TYPE_CHECKING +from threading import Lock from feeluown.excs import ProviderIOError from feeluown.utils import aio @@ -104,6 +105,8 @@ def __init__(self, app: 'App', songs=None, playback_mode=PlaybackMode.loop, #: playlist mode changed signal self.mode_changed = Signal() + #: playback mode before changed to fm mode + self._normal_mode_playback_mode = playback_mode #: store value for ``current_song`` property self._current_song = None @@ -114,6 +117,12 @@ def __init__(self, app: 'App', songs=None, playback_mode=PlaybackMode.loop, #: store value for ``songs`` property self._songs = DedupList(songs or []) + # Acquire this lock before changing _current_song or _songs. + # NOTE: some methods emit some signal while holding the lock, + # the signal handler should pay much attention to avoid deadlock. + # One thing is that the signal handler should not call any method + # that requires the lock!!! + self._songs_lock = Lock() self.audio_select_policy = audio_select_policy @@ -160,10 +169,13 @@ def mode(self): 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 + if mode is PlaylistMode.fm: + self._normal_mode_playback_mode = self.playback_mode + self.playback_mode = PlaybackMode.sequential + else: + self.playback_mode = self._normal_mode_playback_mode self.mode_changed.emit(mode) logger.info('playlist mode changed to %s', mode) @@ -213,6 +225,9 @@ def is_bad(self, song): return song in self._bad_songs def _add(self, song): + """ + Requires: acquire `_songs_lock` before calling this method. + """ if song in self._songs: return self._songs.append(song) @@ -236,11 +251,12 @@ 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) + with self._songs_lock: + 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 @@ -251,20 +267,30 @@ def add(self, song): """ if self._mode is PlaylistMode.fm: self.mode = PlaylistMode.normal - self._add(song) + with self._songs_lock: + self._add(song) def fm_add(self, song): + with self._songs_lock: + self._fm_add_no_lock(song) + + def _fm_add_no_lock(self, song): + """ + Only for unittest and internal usage. + """ self._add(song) - def insert(self, song): + def insert_after_current_song(self, song): """Insert song after current song + Requires: acquire `_songs_lock` before calling this method. + 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._mode is PlaylistMode.fm: + self.mode = PlaylistMode.normal if self._current_song is None: self._add(song) else: @@ -272,12 +298,7 @@ def insert(self, 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. - """ + def remove_no_lock(self, song): try: index = self._songs.index(song) except ValueError: @@ -286,16 +307,19 @@ def remove(self, song): if self._current_song is None: self._songs.remove(song) elif song == self._current_song: - next_song = self.next_song + next_song = self._get_next_song_no_lock() # 随机模式下或者歌单只剩一首歌曲,下一首可能和当前歌曲相同 if next_song == self.current_song: - self.current_song = None + # Should set current song immediately. + # Should not use set_current_song, because it is an async task. + self.set_current_song_none() self._songs.remove(song) - self.current_song = self.next_song + new_next_song = self._get_next_song_no_lock() + self.set_existing_song_as_current_song(new_next_song) else: - next_song = self.next_song + next_song = self._get_next_song_no_lock() self._songs.remove(song) - self.current_song = next_song + self.set_existing_song_as_current_song(next_song) else: self._songs.remove(song) self.songs_removed.emit(index, 1) @@ -303,6 +327,15 @@ def remove(self, song): if song in self._bad_songs: self._bad_songs.remove(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. + """ + with self._songs_lock: + self.remove_no_lock(song) + def init_from(self, songs): warnings.warn( 'use set_models instead, this will be removed in v3.8', @@ -312,13 +345,14 @@ def init_from(self, songs): def clear(self): """remove all songs from playlists""" - if self.current_song is not None: - self.set_current_song_none() - length = len(self._songs) - self._songs.clear() - if length > 0: - self.songs_removed.emit(0, length) - self._bad_songs.clear() + with self._songs_lock: + if self.current_song is not None: + self.set_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""" @@ -338,7 +372,9 @@ def playback_mode(self, playback_mode): self.playback_mode_changed.emit(self.playback_mode) def _get_good_song(self, base=0, random_=False, direction=1, loop=True): - """从播放列表中获取一首可以播放的歌曲 + """Get a good song from playlist. + + Requires: acquire `_songs_lock` before calling this method. :param base: base index :param random_: random strategy or not @@ -384,10 +420,12 @@ def _get_good_song(self, base=0, random_=False, direction=1, loop=True): else: return good_songs[0] - @property - def next_song(self): - """next song for player, calculated based on playback_mode""" - # 如果没有正在播放的歌曲,找列表里面第一首能播放的 + def _get_next_song_no_lock(self): + """ + Requires: acquire `_songs_lock` before calling this method. + """ + assert self._songs_lock.locked() + if self.current_song is None: return self._get_good_song() @@ -408,11 +446,13 @@ def next_song(self): return next_song @property - def previous_song(self): - """previous song for player to play + def next_song(self): + """next song for player, calculated based on playback_mode""" + # 如果没有正在播放的歌曲,找列表里面第一首能播放的 + with self._songs_lock: + return self._get_next_song_no_lock() - NOTE: not the last played song - """ + def _get_previous_song_no_lock(self): if self.current_song is None: return self._get_good_song(base=-1, direction=-1) @@ -423,20 +463,49 @@ def previous_song(self): previous_song = self._get_good_song(base=current_index - 1, direction=-1) return previous_song + @property + def previous_song(self): + """previous song for player to play + + NOTE: not the last played song + """ + with self._songs_lock: + return self._get_previous_song_no_lock() + async def a_next(self): self.next() - def next(self) -> Optional[asyncio.Task]: - if self.next_song is None: + def _next_no_lock(self): + """ + Requires: acquire `_songs_lock` before calling this method. + Only for unittest and internal usage. + """ + next_song = self._get_next_song_no_lock() + if next_song is None: self.eof_reached.emit() return None - else: - return self.set_current_song(self.next_song) + return self.set_existing_song_as_current_song(next_song) + + def next(self) -> Optional[asyncio.Task]: + """ + Why _songs_lock is needed? Think about the following scenario: + + [A, B, C, D] is the playlist, and the current song is B. + + Timeline t1 t2 t3 t4 + User: play_next play D + Player: next_song is C + + The expected song to play is D. So lock is needed here. + """ + with self._songs_lock: + return self._next_no_lock() 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) + with self._songs_lock: + return self.set_existing_song_as_current_song(self.current_song) else: self.next() @@ -458,7 +527,9 @@ async def _fetch_current_song_mv(self, song): def previous(self) -> Optional[asyncio.Task]: """return to the previous song in playlist""" - return self.set_current_song(self.previous_song) + with self._songs_lock: + song = self._get_previous_song_no_lock() + return self.set_existing_song_as_current_song(song) @property def current_song(self) -> Optional[SongModel]: @@ -567,10 +638,12 @@ async def find_and_use_standby(self, song): logger.info(f'song standby was found in {standby.source} ✅') self._app.show_msg(f'在 {standby.source} 平台找到 {song} 的备用歌曲 ✅') # 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) + # TODO: 或许这里可以优化一下?用 self.insert 函数? + with self._songs_lock: + 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) return standby, media logger.info(f'{song} song standby not found') @@ -582,11 +655,12 @@ def set_current_song_with_media(self, song, media, metadata=None): self.set_current_song_none() return # Add it to playlist if song not in playlist. - if song not in self._songs: - self.insert(song) - self._current_song = song - self.song_changed.emit(song) - self.song_changed_v2.emit(song, media) + with self._songs_lock: + self.insert_after_current_song(song) + self._current_song = song + # TODO: 这里可能有点问题。比如 current_song 怎样和 media 保持一致呢? + self.song_changed.emit(song) + self.song_changed_v2.emit(song, media) if media is None: self._app.show_msg("没找到可用的播放链接,播放下一首...") run_afn(self.a_next) @@ -670,13 +744,28 @@ async def a_play_model(self, model): else: fn = self.a_set_current_model upgrade_fn = self._app.library.video_upgrade + try: # Try to upgrade the model. - model = await aio.run_fn(upgrade_fn, model) + umodel = await aio.run_fn(upgrade_fn, model) except ModelNotFound: pass except: # noqa logger.exception(f'upgrade model:{model} failed') + else: + # Replace the brief model with the upgraded model + # when user try to play a brief model that is already in the playlist. + if isinstance(model, BriefSongModel): + with self._songs_lock: + if model in self._songs: + index = self._songs.index(model) + self._songs.insert(index+1, umodel) + if self.current_song == model: + self.set_current_song_none() + else: + self._songs.remove(model) + model = umodel + try: await self._app.task_mgr.run_afn_preemptive( fn, model, name=TASK_SET_CURRENT_MODEL @@ -711,6 +800,13 @@ def set_current_model(self, model) -> asyncio.Task: self.a_set_current_model, model, name=TASK_SET_CURRENT_MODEL, ) + def set_existing_song_as_current_song(self, song): + """ + Requires: acquire `_songs_lock` before calling this method. + """ + self._current_song = song + return self.set_current_song(song) + def set_current_song(self, song): """ .. versionadded:: 3.7.11 diff --git a/feeluown/player/radio.py b/feeluown/player/radio.py index fdaed35c0f..a3e5d7469f 100644 --- a/feeluown/player/radio.py +++ b/feeluown/player/radio.py @@ -1,25 +1,23 @@ from collections import deque +from typing import TYPE_CHECKING, List -from feeluown.library import SupportsSongSimilar +from feeluown.library import SupportsSongSimilar, BriefSongModel + +if TYPE_CHECKING: + from feeluown.app import App def calc_song_similarity(base, song): return 10 -class SongRadio: - def __init__(self, app, song): +class Radio: + def __init__(self, app: 'App', songs: List[BriefSongModel]): self._app = app - self.root_song = song - self._stack = deque([song]) - self._songs_set = set({}) - - @classmethod - def create(cls, app, song): - provider = app.library.get(song.source) - if provider is not None and isinstance(provider, SupportsSongSimilar): - return cls(app, song) - raise ValueError('the provider must support list similar song') + self._stack = deque(songs) + # B is a similar song of A. Also, A may be a similar song of B. + # The songs_set store all songs to avoid fetching duplicate songs. + self._songs_set = set(songs) def fetch_songs_func(self, number): """implement fm.fetch_songs_func @@ -40,9 +38,13 @@ def fetch_songs_func(self, number): if not self._stack: break song = self._stack.popleft() + # User can mark a song as 'dislike' by removing it from playlist. + if song not in self._app.playlist.list(): + continue provider = self._app.library.get(song.source) - # Provider is ensure to SupportsSongsimilar during creating. - assert isinstance(provider, SupportsSongSimilar) + # Provider is ensured to SupportsSongsimilar during creating. + if not isinstance(provider, SupportsSongSimilar): + continue songs = provider.song_list_similar(song) for song in songs: if song not in self._songs_set: @@ -53,3 +55,31 @@ def fetch_songs_func(self, number): self._stack.append(song) self._songs_set.add(song) return valid_songs + + +class SongRadio: + """SongRadio recommend songs based on a song.""" + + def __init__(self, app: 'App', song): + self._app = app + self.root_song = song + self._radio = Radio(app, [song]) + + @classmethod + def create(cls, app, song): + provider = app.library.get(song.source) + if provider is not None and isinstance(provider, SupportsSongSimilar): + return cls(app, song) + raise ValueError('the provider must support list similar song') + + def fetch_songs_func(self, number): + return self._radio.fetch_songs_func(number) + + +class SongsRadio: + def __init__(self, app: 'App', songs: List[BriefSongModel]): + self._app = app + self._radio = Radio(self._app, songs) + + def fetch_songs_func(self, number): + return self._radio.fetch_songs_func(number) diff --git a/tests/player/test_playlist.py b/tests/player/test_playlist.py index 29f1cd03f0..00efe8a911 100644 --- a/tests/player/test_playlist.py +++ b/tests/player/test_playlist.py @@ -2,6 +2,7 @@ from unittest import mock import pytest +import pytest_asyncio from feeluown.library.excs import MediaNotFound from feeluown.player import ( @@ -26,23 +27,23 @@ def pl(app_mock, song, song1): return playlist -@pytest.fixture() -def pl_prepare_media_none(mocker, pl): +@pytest_asyncio.fixture +async def pl_prepare_media_none(mocker, pl): f = asyncio.Future() f.set_exception(MediaNotFound()) mocker.patch.object(Playlist, '_prepare_media', side_effect=f) -@pytest.fixture() -def pl_list_standby_return_empty(mocker, pl): +@pytest_asyncio.fixture +async def pl_list_standby_return_empty(mocker, pl): f2 = asyncio.Future() f2.set_result([]) mock_a_list_standby = pl._app.library.a_list_song_standby_v2 mock_a_list_standby.return_value = f2 -@pytest.fixture() -def pl_list_standby_return_song2(mocker, pl, song2): +@pytest_asyncio.fixture +async def pl_list_standby_return_song2(mocker, pl, song2): f2 = asyncio.Future() f2.set_result([(song2, SONG2_URL)]) mock_a_list_standby = pl._app.library.a_list_song_standby_v2 @@ -72,19 +73,21 @@ def test_remove_song(mocker, pl, song, song1, song2): # remove the current_song # song1 should be set as the current_song - with mock.patch.object(Playlist, 'current_song', - new_callable=mock.PropertyMock) as mock_s: - mock_s.return_value = song - pl.remove(song) - mock_s.assert_called_with(song1) - assert len(pl) == 1 + pl._current_song = song + pl.remove(song) + assert len(pl) == 1 + assert pl.current_song == song1 -def test_set_current_song_with_media(pl, song2): +@pytest.mark.asyncio +async def test_set_current_song_with_media(pl, song2): """ Set a non-existing song as current song, and the song should be inserted after current_song. """ + # Mock the a_next coroutine to avoid resource leak:: + # RuntimeWarning: coroutine 'Playlist.a_next' was never awaited + pl.a_next = mock.MagicMock(return_value=asyncio.Future()) pl.set_current_song_with_media(song2, None) assert pl.current_song == song2 assert pl.list()[1] == song2 @@ -94,11 +97,24 @@ def test_set_current_song_with_media(pl, song2): async def test_play_model(pl, app_mock, song, mocker): f = asyncio.Future() f.set_result(None) - mocker.patch.object(pl, 'set_current_model', return_value=f) app_mock.task_mgr.run_afn_preemptive.return_value = f await pl.a_play_model(song) # The player.resume method must be called. - assert pl._app.player.resume.called + assert app_mock.player.resume.called + + +@pytest.mark.asyncio +async def test_play_a_brief_song_model( + pl, app_mock, library, ekaf_song0, ekaf_brief_song0, mocker): + app_mock.library = library + pl.add(ekaf_brief_song0) + mocker.patch.object(pl, 'a_set_current_song') + await pl.a_play_model(ekaf_brief_song0) + # The song should be upgraded to a normal model + assert ekaf_brief_song0 not in pl.current_song + # Should called with the upgraded song model + app_mock.task_mgr.run_afn_preemptive.assert_called_once_with( + pl.a_set_current_song, ekaf_song0, name='playlist.set_current_model') def test_set_models(pl, song1, song2): @@ -189,12 +205,14 @@ def mock_prepare_metadata(mocker): async def test_playlist_change_mode(app_mock, mocker): # from normal to fm pl = Playlist(app_mock) + old_playback_mode = pl.playback_mode pl.mode = PlaylistMode.fm assert pl.playback_mode is PlaybackMode.sequential # from fm to normal pl.mode = PlaylistMode.normal assert pl.mode is PlaylistMode.normal + assert pl.playback_mode == old_playback_mode @pytest.mark.asyncio @@ -268,8 +286,8 @@ async def test_playlist_resumed_from_eof_reached(app_mock, song, mocker, pl = Playlist(app_mock) def feed_playlist(): - pl.fm_add(song) - pl.next() + pl._fm_add_no_lock(song) + pl._next_no_lock() pl.eof_reached.connect(feed_playlist) pl.mode = PlaylistMode.fm