From 1f6496ed36239f3beb04d3349d81e579a1cb7c26 Mon Sep 17 00:00:00 2001 From: Shaowen Yin Date: Sat, 27 Jul 2024 14:21:33 +0800 Subject: [PATCH] player: slightly refactor metadata assembler (#856) --- feeluown/player/__init__.py | 2 + feeluown/player/metadata_assembler.py | 87 +++++++++++++++++++++++++++ feeluown/player/playlist.py | 82 +++---------------------- tests/player/test_fm.py | 4 +- tests/player/test_playlist.py | 17 +++--- 5 files changed, 107 insertions(+), 85 deletions(-) create mode 100644 feeluown/player/metadata_assembler.py diff --git a/feeluown/player/__init__.py b/feeluown/player/__init__.py index 3206ef4588..78d14665e4 100644 --- a/feeluown/player/__init__.py +++ b/feeluown/player/__init__.py @@ -3,6 +3,7 @@ from .base_player import State from .mpvplayer import MpvPlayer as Player from .playlist import PlaylistMode, Playlist +from .metadata_assembler import MetadataAssembler from .fm import FM from .radio import SongRadio from .lyric import LiveLyric, parse_lyric_text, Line as LyricLine, Lyric @@ -26,6 +27,7 @@ 'Metadata', 'MetadataFields', + 'MetadataAssembler', 'LiveLyric', 'parse_lyric_text', diff --git a/feeluown/player/metadata_assembler.py b/feeluown/player/metadata_assembler.py new file mode 100644 index 0000000000..d5ffc49f0f --- /dev/null +++ b/feeluown/player/metadata_assembler.py @@ -0,0 +1,87 @@ +import logging +from typing import TYPE_CHECKING + +from feeluown.excs import ResourceNotFound, ModelNotFound +from feeluown.library import reverse, SongModel +from feeluown.player import Metadata, MetadataFields +from feeluown.utils import aio + +if TYPE_CHECKING: + from feeluown.app import App + +logger = logging.getLogger(__name__) + + +class MetadataAssembler: + """Cook and fetch metadata for songs and videos""" + def __init__(self, app: 'App'): + self._app = app + + @staticmethod + def cook_basic_metadata_for_song(song): + return 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 '', + }) + + async def fetch_from_song(self, song): + empty_result = ('', '', None) + try: + usong: SongModel = await aio.wait_for( + aio.run_fn(self._app.library.song_upgrade, song), + timeout=1, + ) + except ResourceNotFound: + return empty_result + except: # noqa + logger.exception(f"fetching song's meta failed, song:'{song.title_display}'") + return empty_result + return usong.pic_url, usong.date, usong.album + + async def fetch_from_album(self, album): + empty_result = ('', '') + try: + album = await aio.wait_for( + aio.run_fn(self._app.library.album_upgrade, album), + timeout=1 + ) + except ResourceNotFound: + return empty_result + except: # noqa + logger.warning( + f"fetching album meta failed, album:{album.name}") + return empty_result + return album.cover, album.released + + async def prepare_for_song(self, song): + metadata = self.cook_basic_metadata_for_song(song) + + artwork, released, album = await self.fetch_from_song(song) + if not (artwork and released) and album is not None: + album_cover, album_released = await self.fetch_from_album(album) + # Try to use album meta first. + artwork = album_cover or artwork + released = album_released or released + metadata[MetadataFields.artwork] = artwork + metadata[MetadataFields.released] = released + + return metadata + + async def prepare_for_video(self, video): + 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), + }) + try: + video = await aio.run_fn(self._app.library.video_upgrade, video) + except ModelNotFound as e: + logger.warning(f"can't get cover of video due to {str(e)}") + else: + metadata[MetadataFields.artwork] = video.cover + return metadata diff --git a/feeluown/player/playlist.py b/feeluown/player/playlist.py index d5beaeaeee..82b3910cea 100644 --- a/feeluown/player/playlist.py +++ b/feeluown/player/playlist.py @@ -5,17 +5,16 @@ from enum import IntEnum, Enum from typing import Optional, TYPE_CHECKING -from feeluown.excs import ModelNotFound, ProviderIOError +from feeluown.excs import ProviderIOError from feeluown.utils import aio from feeluown.utils.aio import run_fn, run_afn from feeluown.utils.dispatch import Signal from feeluown.utils.utils import DedupList -from feeluown.player import Metadata, MetadataFields from feeluown.library import ( - MediaNotFound, SongModel, ModelType, ResourceNotFound, VideoModel, + MediaNotFound, SongModel, ModelType, VideoModel, ) from feeluown.media import Media -from feeluown.library import reverse +from .metadata_assembler import MetadataAssembler if TYPE_CHECKING: from feeluown.app import App @@ -27,7 +26,7 @@ class PlaybackMode(IntEnum): """ Playlist playback mode. - .. versiondeprecated:: 3.8.12 + .. deprecated:: 3.8.12 Please use PlaylistRepeatMode and PlaylistShuffleMode instead. """ one_loop = 0 #: One Loop @@ -81,6 +80,7 @@ def __init__(self, app: 'App', songs=None, playback_mode=PlaybackMode.loop, :param playback_mode: :class:`feeluown.player.PlaybackMode` """ self._app = app + self._metadata_mgr = MetadataAssembler(app) #: init playlist mode normal self._mode = PlaylistMode.normal @@ -327,7 +327,7 @@ def _get_good_song(self, base=0, random_=False, direction=1, loop=True): """从播放列表中获取一首可以播放的歌曲 :param base: base index - :param random: random strategy or not + :param random_: random strategy or not :param direction: forward if > 0 else backward :param loop: regard the song list as a loop @@ -526,7 +526,7 @@ async def a_set_current_song(self, song): self.mark_as_bad(song) target_song, media = await self.find_and_use_standby(song) - metadata = await self._prepare_metadata_for_song(target_song) + metadata = await self._metadata_mgr.prepare_for_song(target_song) self.pure_set_current_song(target_song, media, metadata) async def a_set_current_song_children(self, song): @@ -590,72 +590,6 @@ def pure_set_current_song(self, song, media, metadata=None): else: self._app.player.stop() - async def _prepare_metadata_for_song(self, song): - 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 '', - }) - try: - song: SongModel = await aio.wait_for( - aio.run_fn(self._app.library.song_upgrade, song), - timeout=1, - ) - except ResourceNotFound: - return metadata - except: # noqa - logger.exception(f"fetching song's meta failed, song:'{song.title_display}'") - return metadata - - artwork = song.pic_url - released = song.date - if not (artwork and released) and song.album is not None: - try: - album = await aio.wait_for( - aio.run_fn(self._app.library.album_upgrade, song.album), - timeout=1 - ) - except ResourceNotFound: - pass - except: # noqa - logger.warning( - f"fetching song's album meta failed, song:{song.title_display}") - else: - artwork = album.cover or artwork - released = album.released or released - # For model v1, the cover can be a Media object. - # For example, in fuo_local plugin, the album.cover is a Media - # object with url set to fuo://local/songs/{identifier}/data/cover. - if isinstance(artwork, Media): - artwork = artwork.url - - # Try to use album meta first. - if artwork and released: - metadata[MetadataFields.artwork] = artwork - metadata[MetadataFields.released] = released - else: - metadata[MetadataFields.artwork] = song.pic_url or artwork - metadata[MetadataFields.released] = song.date or released - return metadata - - async def _prepare_metadata_for_video(self, video): - 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), - }) - try: - video = await aio.run_fn(self._app.library.video_upgrade, video) - except ModelNotFound as e: - logger.warning(f"can't get cover of video due to {str(e)}") - else: - metadata[MetadataFields.artwork] = video.cover - return metadata - async def _prepare_media(self, song): task_spec = self._app.task_mgr.get_or_create('prepare-media') # task_spec.disable_default_cb() @@ -710,7 +644,7 @@ async def a_set_current_model(self, model): except MediaNotFound: self._app.show_msg('没有可用的播放链接') else: - metadata = await self._prepare_metadata_for_video(video) + metadata = await self._metadata_mgr.prepare_for_video(video) kwargs = {} if not self._app.has_gui: kwargs['video'] = False diff --git a/tests/player/test_fm.py b/tests/player/test_fm.py index 177032651a..90a3c23c9a 100644 --- a/tests/player/test_fm.py +++ b/tests/player/test_fm.py @@ -3,7 +3,7 @@ import pytest from feeluown.excs import ProviderIOError -from feeluown.player import Playlist, PlaylistMode, FM +from feeluown.player import Playlist, PlaylistMode, FM, MetadataAssembler from feeluown.task import TaskManager @@ -76,7 +76,7 @@ async def test_multiple_eof_reached_signal(app_mock, song, mocker): async def test_reactivate_fm_mode_after_playing_other_songs( app_mock, song, song1, mocker): - mocker.patch.object(Playlist, '_prepare_metadata_for_song') + mocker.patch.object(MetadataAssembler, 'prepare_for_song') def f(*args, **kwargs): return [song1] diff --git a/tests/player/test_playlist.py b/tests/player/test_playlist.py index ae8dfa9453..791e8c561e 100644 --- a/tests/player/test_playlist.py +++ b/tests/player/test_playlist.py @@ -4,10 +4,9 @@ import pytest from feeluown.library.excs import MediaNotFound -from feeluown.media import Media from feeluown.player import ( Playlist, PlaylistMode, Player, PlaybackMode, - PlaylistRepeatMode, PlaylistShuffleMode, + PlaylistRepeatMode, PlaylistShuffleMode, MetadataAssembler ) from feeluown.utils.dispatch import Signal @@ -116,7 +115,7 @@ async def test_set_current_song_with_bad_song_1( mock_pure_set_current_song = mocker.patch.object(Playlist, 'pure_set_current_song') mock_mark_as_bad = mocker.patch.object(Playlist, 'mark_as_bad') sentinal = object() - mocker.patch.object(Playlist, '_prepare_metadata_for_song', return_value=sentinal) + mocker.patch.object(MetadataAssembler, 'prepare_for_song', return_value=sentinal) await pl.a_set_current_song(song2) # A song that has no valid media should be marked as bad assert mock_mark_as_bad.called @@ -132,7 +131,7 @@ async def test_set_current_song_with_bad_song_2( mock_pure_set_current_song = mocker.patch.object(Playlist, 'pure_set_current_song') mock_mark_as_bad = mocker.patch.object(Playlist, 'mark_as_bad') sentinal = object() - mocker.patch.object(Playlist, '_prepare_metadata_for_song', return_value=sentinal) + mocker.patch.object(MetadataAssembler, 'prepare_for_song', return_value=sentinal) await pl.a_set_current_song(song2) # A song that has no valid media should be marked as bad assert mock_mark_as_bad.called @@ -150,7 +149,7 @@ async def test_set_current_song_with_bad_song_3( mock_pure_set_current_song = mocker.patch.object(Playlist, 'pure_set_current_song') mock_prepare_mv_media = mocker.patch.object(Playlist, '_prepare_mv_media', return_value=media) - mocker.patch.object(Playlist, '_prepare_metadata_for_song', return_value=metadata) + mocker.patch.object(MetadataAssembler, 'prepare_for_song', return_value=metadata) app_mock.config.ENABLE_MV_AS_STANDBY = 1 pl = Playlist(app_mock) @@ -181,7 +180,7 @@ async def test_set_an_existing_bad_song_as_current_song( song1 is bad, standby is [song2] play song1, song2 should be insert after song1 instead of song """ - mocker.patch.object(Playlist, '_prepare_metadata_for_song') + mocker.patch.object(MetadataAssembler, 'prepare_for_song') await pl.a_set_current_song(song1) assert pl.list().index(song2) == 2 @@ -299,7 +298,7 @@ async def test_play_next_bad_song(app_mock, song, song1, mocker): be marked as bad. Besides, it should try to find standby. """ mock_pure_set_current_song = mocker.patch.object(Playlist, 'pure_set_current_song') - mocker.patch.object(Playlist, '_prepare_metadata_for_song', return_value=object()) + mocker.patch.object(MetadataAssembler, 'prepare_for_song', return_value=object()) mock_standby = mocker.patch.object(Playlist, 'find_and_use_standby', return_value=(song1, None)) @@ -358,7 +357,7 @@ def test_playlist_next_should_call_set_current_song(app_mock, mocker, song): async def test_playlist_prepare_metadata_for_song( app_mock, library, pl, ekaf_brief_song0, mocker): class Album: - cover = Media('fuo://') + cover = 'http://' released = '2018-01-01' app_mock.library = library @@ -366,4 +365,4 @@ class Album: mocker.patch.object(library, 'album_upgrade', return_value=album) # app_mock.library.album_upgrade.return_value = album # When cover is a media object, prepare_metadata should also succeed. - await pl._prepare_metadata_for_song(ekaf_brief_song0) + await pl._metadata_mgr.prepare_for_song(ekaf_brief_song0)