diff --git a/feeluown/app/config.py b/feeluown/app/config.py index 33b4b0778..c1e17edf4 100644 --- a/feeluown/app/config.py +++ b/feeluown/app/config.py @@ -72,4 +72,26 @@ def create_config() -> Config: config.deffield( 'PLAYBACK_CROSSFADE_DURATION', type_=int, default=500, desc='淡入淡出持续时间' ) + config.deffield( + 'OPENAI_API_BASEURL', + type_=str, + default='', + desc='OpenAI API base url' + ) + config.deffield('OPENAI_API_KEY', type_=str, default='', desc='OpenAI API key') + config.deffield('OPENAI_MODEL', type_=str, default='', desc='OpenAI model name') + config.deffield( + 'AI_RADIO_PROMPT', + type_=str, + default='''\ +你是一个音乐推荐系统。你根据用户的歌曲列表分析用户的喜好,给用户推荐一些歌。默认推荐5首歌。 + +有几个注意点 +1. 不要推荐与用户播放列表中一模一样的歌曲。不要推荐用户不喜欢的歌曲。不要重复推荐。 +2. 你返回的内容只应该有 JSON,其它信息都不需要。也不要用 markdown 格式返回。 +3. 你推荐的歌曲需要使用类似这样的 JSON 格式 + [{"title": "xxx", "artists_name": "yyy", "description": "推荐理由"}] +''', + desc='AI 电台功能的提示词' + ) return config diff --git a/feeluown/gui/uimain/playlist_overlay.py b/feeluown/gui/uimain/playlist_overlay.py index b7ac7fffa..4f5216f3d 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, SongsRadio +from feeluown.player import PlaybackMode, SongsRadio, AIRadio, AI_RADIO_SUPPORTED 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 @@ -46,6 +46,7 @@ def __init__(self, app, *args, **kwargs): self._playback_mode_switch = PlaybackModeSwitch(app) self._goto_current_song_btn = TextButton('跳转到当前歌曲') self._songs_radio_btn = TextButton('自动续歌') + self._ai_radio_btn = TextButton('AI电台') # Please update the list when you add new buttons. self._btns = [ self._clear_playlist_btn, @@ -65,6 +66,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) + self._ai_radio_btn.clicked.connect(self.enter_ai_radio) esc_hide_widget(self) q_app = QApplication.instance() assert q_app is not None # make type checker happy. @@ -72,6 +74,16 @@ def __init__(self, app, *args, **kwargs): q_app.focusChanged.connect(self.on_focus_changed) # type: ignore self._app.installEventFilter(self) self._tabbar.currentChanged.connect(self.show_tab) + + if ( + AI_RADIO_SUPPORTED is True + and self._app.config.OPENAI_API_KEY + and self._app.config.OPENAI_MODEL + and self._app.config.OPENAI_API_BASEURL + ): + self._ai_radio_btn.clicked.connect(self.enter_ai_radio) + else: + self._ai_radio_btn.setDisabled(True) self.setup_ui() def setup_ui(self): @@ -97,6 +109,7 @@ def setup_ui(self): 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_layout2.addWidget(self._ai_radio_btn) self._btn_layout.addStretch(0) self._btn_layout2.addStretch(0) @@ -125,6 +138,14 @@ def enter_songs_radio(self): self._app.fm.activate(radio.fetch_songs_func, reset=False) self._app.show_msg('“自动续歌”功能已激活') + def enter_ai_radio(self): + if self._app.playlist.list(): + radio = AIRadio(self._app) + self._app.fm.activate(radio.fetch_songs_func, reset=False) + self._app.show_msg('已经进入 AI 电台模式 ~') + else: + self._app.show_msg('播放列表为空,暂时不能开启 AI 电台') + def show_tab(self, index): if not self.isVisible(): return diff --git a/feeluown/gui/widgets/settings.py b/feeluown/gui/widgets/settings.py index 0e533c118..f289fb7c5 100644 --- a/feeluown/gui/widgets/settings.py +++ b/feeluown/gui/widgets/settings.py @@ -1,6 +1,6 @@ from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QDialog, QWidget, QCheckBox, \ - QVBoxLayout, QHBoxLayout + QVBoxLayout, QHBoxLayout, QPlainTextEdit, QPushButton from feeluown.gui.widgets.magicbox import KeySourceIn from feeluown.gui.widgets.header import MidHeader @@ -65,6 +65,26 @@ def __init__(self, app, *args, **kwargs): self._layout.addStretch(0) +class AISettings(QWidget): + def __init__(self, app, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._app = app + self._prompt_editor = QPlainTextEdit(self) + self._save_btn = QPushButton('保存', self) + + self._layout = QHBoxLayout(self) + self._layout.addWidget(self._prompt_editor) + self._layout.addWidget(self._save_btn) + self._prompt_editor.setPlainText(self._app.config.AI_RADIO_PROMPT) + self._prompt_editor.setMaximumHeight(200) + + self._save_btn.clicked.connect(self.save_prompt) + + def save_prompt(self): + self._app.config.AI_RADIO_PROMPT = self._prompt_editor.toPlainText() + + class SettingsDialog(QDialog): def __init__(self, app, parent=None): super().__init__(parent=parent) @@ -86,6 +106,8 @@ def render(self): self._layout = QVBoxLayout(self) self._layout.addWidget(MidHeader('搜索来源')) self._layout.addWidget(toolbar) + self._layout.addWidget(MidHeader('AI 电台(PROMPT)')) + self._layout.addWidget(AISettings(self._app)) self._layout.addWidget(MidHeader('播放器')) self._layout.addWidget(PlayerSettings(self._app)) self._layout.addStretch(0) diff --git a/feeluown/library/library.py b/feeluown/library/library.py index d1912f74c..acd255083 100644 --- a/feeluown/library/library.py +++ b/feeluown/library/library.py @@ -22,6 +22,7 @@ SupportsSongLyric, SupportsSongMV, SupportsSongMultiQuality, SupportsVideoMultiQuality, SupportsSongWebUrl, SupportsVideoWebUrl, ) +from .similarity import get_standby_origin_similarity, FULL_SCORE if TYPE_CHECKING: from .ytdl import Ytdl @@ -29,7 +30,6 @@ logger = logging.getLogger(__name__) -FULL_SCORE = 10 MIN_SCORE = 5 T_p = TypeVar('T_p') @@ -38,48 +38,6 @@ def raise_(e): raise e -def default_score_fn(origin, standby): - - # TODO: move this function to utils module - def duration_ms_to_duration(ms): - if not ms: # ms is empty - return 0 - parts = ms.split(':') - assert len(parts) in (2, 3), f'invalid duration format: {ms}' - if len(parts) == 3: - h, m, s = parts - else: - m, s = parts - h = 0 - return int(h) * 3600 + int(m) * 60 + int(s) - - score = FULL_SCORE - if origin.artists_name != standby.artists_name: - score -= 3 - if origin.title != standby.title: - score -= 2 - if origin.album_name != standby.album_name: - score -= 2 - - if isinstance(origin, SongModel): - origin_duration = origin.duration - else: - origin_duration = duration_ms_to_duration(origin.duration_ms) - if isinstance(standby, SongModel): - standby_duration = standby.duration - else: - standby_duration = duration_ms_to_duration(standby.duration_ms) - if abs(origin_duration - standby_duration) / max(origin_duration, 1) > 0.1: - score -= 3 - - # Debug code for score function - # print(f"{score}\t('{standby.title}', " - # f"'{standby.artists_name}', " - # f"'{standby.album_name}', " - # f"'{standby.duration_ms}')") - return score - - class Library: """Resource entrypoints.""" @@ -214,7 +172,7 @@ async def prepare_media(standby, policy): else: pvd_ids = [pvd.identifier for pvd in self._filter(identifier_in=source_in)] if score_fn is None: - score_fn = default_score_fn + score_fn = get_standby_origin_similarity limit = max(limit, 1) q = '{} {}'.format(song.title_display, song.artists_name_display) diff --git a/feeluown/library/similarity.py b/feeluown/library/similarity.py new file mode 100644 index 000000000..759a2cf93 --- /dev/null +++ b/feeluown/library/similarity.py @@ -0,0 +1,56 @@ +from .models import SongModel + +FULL_SCORE = 10 + + +def get_standby_origin_similarity(origin, standby): + + # TODO: move this function to utils module + def duration_ms_to_duration(ms): + if not ms: # ms is empty + return 0 + parts = ms.split(':') + assert len(parts) in (2, 3), f'invalid duration format: {ms}' + if len(parts) == 3: + h, m, s = parts + else: + m, s = parts + h = 0 + return int(h) * 3600 + int(m) * 60 + int(s) + + score = FULL_SCORE + unsure_score = 0 + if origin.artists_name != standby.artists_name: + score -= 3 + if origin.title != standby.title: + score -= 2 + # Only compare album_name when it is not empty. + if origin.album_name: + if origin.album_name != standby.album_name: + score -= 2 + else: + score -= 1 + unsure_score += 2 + + if isinstance(origin, SongModel): + origin_duration = origin.duration + else: + origin_duration = duration_ms_to_duration(origin.duration_ms) + if isinstance(standby, SongModel): + standby_duration = standby.duration + else: + standby_duration = duration_ms_to_duration(standby.duration_ms) + # Only compare duration when it is not empty. + if origin_duration: + if abs(origin_duration - standby_duration) / max(origin_duration, 1) > 0.1: + score -= 3 + else: + score -= 1 + unsure_score += 3 + + # Debug code for score function + # print(f"{score}\t('{standby.title}', " + # f"'{standby.artists_name}', " + # f"'{standby.album_name}', " + # f"'{standby.duration_ms}')") + return ((score - unsure_score) / (FULL_SCORE - unsure_score)) * FULL_SCORE diff --git a/feeluown/library/text2song.py b/feeluown/library/text2song.py new file mode 100644 index 000000000..3accf2668 --- /dev/null +++ b/feeluown/library/text2song.py @@ -0,0 +1,64 @@ +import uuid +import json + +from .models import BriefSongModel, ModelState + + +class AnalyzeError(Exception): + pass + + +def analyze_text(text): + def json_fn(each): + try: + return each['title'], each['artists_name'] + except KeyError: + return None + + def line_fn(line): + parts = line.split('|') + if len(parts) == 2 and parts[0]: # title should not be empty + return (parts[0], parts[1]) + return None + + try: + data = json.loads(text) + except json.JSONDecodeError: + lines = text.strip().split('\n') + if lines: + first_line = lines[0].strip() + if first_line in ('---', '==='): + parse_each_fn = line_fn + items = [each.strip() for each in lines[1:] if each.strip()] + elif first_line == '```json': + try: + items = json.loads(text[7:-3]) + except json.JSONDecodeError: + raise AnalyzeError('invalid JSON content inside code block') + parse_each_fn = json_fn + else: + raise AnalyzeError('invalid JSON content') + else: + if not isinstance(data, list): + # should be like [{"title": "xxx", "artists_name": "yyy"}] + raise AnalyzeError('content has invalid format') + parse_each_fn = json_fn + items = data + + err_count = 0 + songs = [] + for each in items: + result = parse_each_fn(each) + if result is not None: + title, artists_name = result + song = BriefSongModel( + source='dummy', + identifier=str(uuid.uuid4()), + title=title, + artists_name=artists_name, + state=ModelState.not_exists, + ) + songs.append(song) + else: + err_count += 1 + return songs, err_count diff --git a/feeluown/player/__init__.py b/feeluown/player/__init__.py index 90ed4a4b4..71ea22af4 100644 --- a/feeluown/player/__init__.py +++ b/feeluown/player/__init__.py @@ -7,6 +7,7 @@ from .metadata_assembler import MetadataAssembler from .fm import FM from .radio import SongRadio, SongsRadio +from .ai_radio import AIRadio, AI_RADIO_SUPPORTED from .lyric import LiveLyric, parse_lyric_text, Line as LyricLine, Lyric from .recently_played import RecentlyPlayed from .delegate import PlayerPositionDelegate @@ -23,6 +24,8 @@ 'PlaylistMode', 'SongRadio', 'SongsRadio', + 'AIRadio', + 'AI_RADIO_SUPPORTED', 'Player', 'Playlist', diff --git a/feeluown/player/ai_radio.py b/feeluown/player/ai_radio.py new file mode 100644 index 000000000..2c23a8dbf --- /dev/null +++ b/feeluown/player/ai_radio.py @@ -0,0 +1,119 @@ +import logging +from typing import TYPE_CHECKING + +from feeluown.library import BriefSongModel, reverse +from feeluown.library.text2song import analyze_text, AnalyzeError + +try: + from openai import OpenAI, AuthenticationError +except ImportError: + AI_RADIO_SUPPORTED = False +else: + AI_RADIO_SUPPORTED = True + +if TYPE_CHECKING: + from feeluown.app import App + + +logger = logging.getLogger(__name__) + + +def song2line(song: BriefSongModel): + line = reverse(song, as_line=True) + parts = line.split('#', 1) + if len(parts) >= 2: + return parts[1] + return None + + +class AIRadio: + def __init__(self, app: 'App'): + self._app = app + + self._messages = [] + self._messages.append({"role": "system", + "content": self._app.config.AI_RADIO_PROMPT}) + + def fetch_songs_func(self, _): + client = OpenAI( + api_key=self._app.config.OPENAI_API_KEY, + base_url=self._app.config.OPENAI_API_BASEURL, + ) + # NOTE(cosven): 经过一些手动测试,我发现“策略”对推荐结果影响很大。 + # + # 目前的策略是使用多轮对话,让 AI 知道自己之前推荐过什么样的歌曲, + # 来避免重复推荐。这样的效果是不错的。 + # + # 尝试过,但效果不好的策略之一:即使把用户不喜欢的歌曲列表提供给 AI, + # AI 仍然会推不喜欢的歌曲,所以这里不提供用户不喜欢的歌曲列表。所有的模型都这样。 + # 举个例子:kimi 和 deepseek 很喜欢推荐“田馥甄”的“小幸运”,即使我说自己不喜欢, + # 它还是又很高概率推荐。可能需要一个更强大的 PROMPT :( + self._messages.append({ + "role": "user", + "content": ( + f"当前播放列表内容如下:\n{self.get_msg()}") + }) + try: + response = client.chat.completions.create( + model=self._app.config.OPENAI_MODEL, + messages=self._messages, + ) + except AuthenticationError: + logger.exception('OpenAI authentication failed') + self._app.show_msg('OpenAI API key 验证失败') + return [] + except Exception: + self._app.show_msg('OpenAI API 接口调用失败') + logger.exception('OpenAI request failed') + return [] + msg = response.choices[0].message + self._messages.append(msg) + for _msg in self._messages[-2:]: + logger.info(f"AI radio, message: {dict(_msg)['content']}") + try: + songs, err_count = analyze_text(str(msg.content)) + except AnalyzeError: + logger.exception('Analyze AI response failed') + return [] + logger.info(f'AI recommend {len(songs)} songs, err_count={err_count}') + return songs + + def get_msg(self): + msg_lines = [] + for song in self._app.playlist.list(): + if self._app.playlist.is_bad(song): + continue + line = song2line(song) + if line is not None: + msg_lines.append(line) + return '\n'.join(msg_lines) + + +# For debugging. +if __name__ == '__main__': + from unittest.mock import MagicMock + + app = MagicMock() + app.config.OPENAI_API_KEY = 'xxx' + app.config.OPENAI_API_BASEURL = 'https://api.deepseek.com' + app.config.OPENAI_MODEL = 'deepseek-chat' + app.config.AI_RADIO_PROMPT = '''\ +你是一个音乐推荐系统。你根据用户的歌曲列表分析用户的喜好,给用户推荐一些歌。默认推荐5首歌。 + +有几个注意点 +1. 不要推荐与用户播放列表中一模一样的歌曲。不要推荐用户不喜欢的歌曲。不要重复推荐。 +2. 你返回的内容只应该有 JSON,其它信息都不需要。也不要用 markdown 格式返回。 +3. 你推荐的歌曲需要使用类似这样的 JSON 格式 + [{"title": "xxx", "artists_name": "yyy", "description": "推荐理由"}] +''' + radio = AIRadio(app) + radio.get_msg = MagicMock(return_value=''' +雨蝶 - 李翊君 - 经典再回首 - 03:52 +海阔天空 - Beyond - 华纳超极品音色系列 - 03:59 +等待 - 韩磊 - 帝王之声 - 03:39 +向天再借五百年 - 韩磊 - 向天再借五百年 - 03:12 +你是我的眼 - 萧煌奇 - 你是我的眼 - 05:21 +''') + songs = radio.fetch_songs_func(5) + for song in songs: + print(song) diff --git a/feeluown/player/playlist.py b/feeluown/player/playlist.py index d4fed7c4b..1bbf5d9a4 100644 --- a/feeluown/player/playlist.py +++ b/feeluown/player/playlist.py @@ -601,12 +601,6 @@ async def a_set_current_song(self, song): self._app.show_msg('使用音乐视频作为其播放资源 ✅') else: self._app.show_msg('未找到可用的音乐视频资源 🙁') - # if mode is fm mode, do not find standby song, just skip the song. - if self.mode is PlaylistMode.fm: - self.mark_as_bad(song) - run_afn(self.a_next) - return - logger.info(f"no media found for {song}, mark it as bad") self.mark_as_bad(song) self.play_model_stage_changed.emit(PlaylistPlayModelStage.find_standby) diff --git a/feeluown/player/radio.py b/feeluown/player/radio.py index a3e5d7469..05b9f86ab 100644 --- a/feeluown/player/radio.py +++ b/feeluown/player/radio.py @@ -7,10 +7,6 @@ from feeluown.app import App -def calc_song_similarity(base, song): - return 10 - - class Radio: def __init__(self, app: 'App', songs: List[BriefSongModel]): self._app = app diff --git a/tests/library/test_library.py b/tests/library/test_library.py index 88d91d0ce..00bba36de 100644 --- a/tests/library/test_library.py +++ b/tests/library/test_library.py @@ -1,5 +1,3 @@ -import random - import pytest from feeluown.library import ( @@ -14,52 +12,6 @@ async def test_library_a_search(library): assert result.q == 'xxx' -def test_score_fn(): - """A test to check if our score fn works well - """ - from feeluown.library.models import BriefSongModel - from feeluown.library.library import default_score_fn, FULL_SCORE, MIN_SCORE - - def create_song(title, artists_name, album_name, duration_ms): - return BriefSongModel(identifier=random.randint(0, 1000), - source='x', - title=title, - artists_name=artists_name, - album_name=album_name, - duration_ms=duration_ms) - - # 一个真实案例:歌曲来自 a 平台,候选歌曲来自 b 平台的搜索结果。 - # 人为判断,第一个结果是符合预期的。 - # - # 这里有一个特点:第一个结果的标题有个后缀,但这个后缀是多余且无影响的信息。 - # 这个现象在 kuwo 平台非常常见。 - song = create_song('暖暖', '梁静茹', '亲亲', '04:03') - candidates = [create_song(*x) for x in [ - ('暖暖-《周末父母》电视剧片头曲', '梁静茹', '亲亲', '04:06'), - ('暖暖', '梁静茹', '“青春19潮我看”湖南卫视2018-2019跨年演唱会', '01:57'), - ('暖暖 (2011音乐万万岁现场)', '梁静茹', '', '03:50'), - ('暖暖 (2015江苏卫跨年演唱会)', '梁静茹', '', '02:05'), - ('暖暖 (纯音乐)', '梁静茹', '', '03:20'), - ('暖暖 (DJ版)', '梁静茹', '', '03:16'), - ('暖暖(Cover 梁静茹)', '釉哥哥&光光', '暖暖-梁静茹', '04:06'), - ]] - assert default_score_fn(song, candidates[0]) > \ - default_score_fn(song, candidates[1]) - - # 字符串上一模一样,理应返回满分 - song = create_song('我很想爱他', 'Twins', '八十块环游世界', '04:27') - candidates = [create_song('我很想爱他', 'Twins', '八十块环游世界', '04:27')] - assert default_score_fn(song, candidates[0]) == FULL_SCORE - - # 根据人工判断,分数应该有 9 分,期望目标算法最起码不能忽略这首歌曲 - song = create_song('很爱很爱你 (Live)', '刘若英', - '脱掉高跟鞋 世界巡回演唱会', '05:55') - candidates = [ - create_song('很爱很爱你', '刘若英', '脱掉高跟鞋世界巡回演唱会', '05:24') - ] - assert default_score_fn(song, candidates[0]) >= MIN_SCORE - - def test_library_model_get(library, ekaf_provider, ekaf_album0): album = library.model_get(ekaf_provider.identifier, ModelType.album, @@ -104,7 +56,7 @@ def search(self, *_, **__): ) library.register(GoodProvider()) - song = BriefSongModel(identifier='1', title='try-to-find-standby', source='xxx') + song = BriefSongModel(identifier='1', title='', source='xxx') song_media_list = await library.a_list_song_standby_v2(song) assert song_media_list assert song_media_list[0][1].url == 'good.mp3' diff --git a/tests/library/test_similarity.py b/tests/library/test_similarity.py new file mode 100644 index 000000000..94e03a9b7 --- /dev/null +++ b/tests/library/test_similarity.py @@ -0,0 +1,67 @@ +import random + +from feeluown.library.similarity import get_standby_origin_similarity, FULL_SCORE +from feeluown.library import BriefSongModel +from feeluown.library.library import MIN_SCORE + + +def test_get_standby_origin_similarity_1(): + origin = BriefSongModel( + source='', + identifier='', + title='x', + artists_name='y', + ) + standby = BriefSongModel( + source='', + identifier='', + title='z', + artists_name='y', + ) + # 对于上面这种情况,不应该匹配上。 + assert get_standby_origin_similarity(origin, standby) < MIN_SCORE + + +def test_get_standby_origin_similarity_2(): + """A test to check if our score fn works well + """ + score_fn = get_standby_origin_similarity + + def create_song(title, artists_name, album_name, duration_ms): + return BriefSongModel(identifier=random.randint(0, 1000), + source='x', + title=title, + artists_name=artists_name, + album_name=album_name, + duration_ms=duration_ms) + + # 一个真实案例:歌曲来自 a 平台,候选歌曲来自 b 平台的搜索结果。 + # 人为判断,第一个结果是符合预期的。 + # + # 这里有一个特点:第一个结果的标题有个后缀,但这个后缀是多余且无影响的信息。 + # 这个现象在 kuwo 平台非常常见。 + song = create_song('暖暖', '梁静茹', '亲亲', '04:03') + candidates = [create_song(*x) for x in [ + ('暖暖-《周末父母》电视剧片头曲', '梁静茹', '亲亲', '04:06'), + ('暖暖', '梁静茹', '“青春19潮我看”湖南卫视2018-2019跨年演唱会', '01:57'), + ('暖暖 (2011音乐万万岁现场)', '梁静茹', '', '03:50'), + ('暖暖 (2015江苏卫跨年演唱会)', '梁静茹', '', '02:05'), + ('暖暖 (纯音乐)', '梁静茹', '', '03:20'), + ('暖暖 (DJ版)', '梁静茹', '', '03:16'), + ('暖暖(Cover 梁静茹)', '釉哥哥&光光', '暖暖-梁静茹', '04:06'), + ]] + assert score_fn(song, candidates[0]) > \ + score_fn(song, candidates[1]) + + # 字符串上一模一样,理应返回满分 + song = create_song('我很想爱他', 'Twins', '八十块环游世界', '04:27') + candidates = [create_song('我很想爱他', 'Twins', '八十块环游世界', '04:27')] + assert score_fn(song, candidates[0]) == FULL_SCORE + + # 根据人工判断,分数应该有 9 分,期望目标算法最起码不能忽略这首歌曲 + song = create_song('很爱很爱你 (Live)', '刘若英', + '脱掉高跟鞋 世界巡回演唱会', '05:55') + candidates = [ + create_song('很爱很爱你', '刘若英', '脱掉高跟鞋世界巡回演唱会', '05:24') + ] + assert score_fn(song, candidates[0]) >= MIN_SCORE