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..fa4461799 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,11 @@ 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: + self._ai_radio_btn.clicked.connect(self.enter_ai_radio) + else: + self._ai_radio_btn.setDisabled() self.setup_ui() def setup_ui(self): @@ -97,6 +104,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 +133,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..6eaff0bb7 100644 --- a/feeluown/library/library.py +++ b/feeluown/library/library.py @@ -54,12 +54,18 @@ def duration_ms_to_duration(ms): 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 - if origin.album_name != standby.album_name: - 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 @@ -69,15 +75,20 @@ def duration_ms_to_duration(ms): 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 + # 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 + return ((score - unsure_score) / (FULL_SCORE - unsure_score)) * FULL_SCORE class Library: 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..07a1bb2ee --- /dev/null +++ b/feeluown/player/ai_radio.py @@ -0,0 +1,83 @@ +import logging +from typing import TYPE_CHECKING + +from feeluown.library import BriefSongModel, reverse +from feeluown.library.text2song import analyze_text, AnalyzeError +from feeluown.utils.utils import DedupList + +try: + from openai import OpenAI +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._unliked_songs = DedupList() + self._app.playlist.songs_removed.connect(self._on_songs_removed, weak=True) + + 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, + ) + 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) + msg = '\n'.join(msg_lines) + # umsg_lines = [] + # for song in self._unliked_songs[-10:]: + # line = song2line(song) + # if line is not None: + # umsg_lines.append(line) + # umsg = '\n'.join(umsg_lines) if umsg_lines else '暂时没有不喜欢的歌曲' + self._messages.append({ + "role": "user", + "content": ( + f"当前播放列表内容如下:\n{msg}") + }) + response = client.chat.completions.create( + model=self._app.config.OPENAI_MODEL, + messages=self._messages, + ) + 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 _on_songs_removed(self, index, count): + songs = self._app.playlist[index: index + count] + self._unliked_songs.extend(songs) diff --git a/feeluown/player/playlist.py b/feeluown/player/playlist.py index d4fed7c4b..33cfe1f1c 100644 --- a/feeluown/player/playlist.py +++ b/feeluown/player/playlist.py @@ -303,6 +303,7 @@ def remove_no_lock(self, song): except ValueError: logger.debug('Remove failed: {} not in playlist'.format(song)) else: + self.songs_removed.emit(index, 1) if self._current_song is None: self._songs.remove(song) elif song == self._current_song: @@ -326,7 +327,6 @@ def remove_no_lock(self, song): self.set_existing_song_as_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) @@ -346,8 +346,8 @@ def _replace_song_no_lock(self, model, umodel): self.songs_added.emit(index+1, 1) if self.current_song == model: self.set_current_song_none() - self._songs.remove(model) self.songs_removed.emit(index, 1) + self._songs.remove(model) def clear(self): """remove all songs from playlists""" @@ -355,9 +355,9 @@ def clear(self): 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._songs.clear() self._bad_songs.clear() def list(self): @@ -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)