From d9979166ace4281aaa58427d0ae12ea6e32e00da Mon Sep 17 00:00:00 2001 From: Shaowen Yin Date: Mon, 3 Feb 2025 00:00:21 +0800 Subject: [PATCH] [feat](AI) add several AI related features (#901) - [x] user can input `=== {question}` to chat with AI - [x] find standby using AI if standby is not found - [x] add a config - [x] user can input `==> {title} - {artists_name}` in search bar to play a song - [x] user can create a playlist based on plain text --- .github/workflows/build.yml | 2 +- .github/workflows/macos-release.yml | 2 +- .github/workflows/win-release.yml | 2 +- feeluown/ai.py | 37 +++ feeluown/app/app.py | 25 +- feeluown/app/config.py | 3 +- feeluown/gui/drawers.py | 109 ++++++-- feeluown/gui/ui.py | 12 +- feeluown/gui/uimain/ai_chat.py | 264 ++++++++++++++++++ feeluown/gui/uimain/sidebar.py | 27 +- feeluown/gui/widgets/__init__.py | 2 +- feeluown/gui/widgets/magicbox.py | 46 ++- feeluown/gui/widgets/selfpaint_btn.py | 12 + feeluown/library/ai_standby.py | 248 ++++++++++++++++ feeluown/library/library.py | 135 ++++++--- feeluown/library/similarity.py | 56 ---- feeluown/library/standby.py | 51 ++++ feeluown/library/text2song.py | 25 +- feeluown/player/ai_radio.py | 5 +- feeluown/utils/aio.py | 13 + setup.py | 3 + .../{test_similarity.py => test_standby.py} | 27 +- 22 files changed, 943 insertions(+), 163 deletions(-) create mode 100644 feeluown/ai.py create mode 100644 feeluown/gui/uimain/ai_chat.py create mode 100644 feeluown/library/ai_standby.py delete mode 100644 feeluown/library/similarity.py create mode 100644 feeluown/library/standby.py rename tests/library/{test_similarity.py => test_standby.py} (79%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f4a9079ef5..00bad21c7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,7 +57,7 @@ jobs: pip install --upgrade pip pip install pyqt5 pip install "pytest<7.2" - pip install -e .[dev,cookies,webserver,ytdl] + pip install -e .[dev,cookies,webserver,ytdl,ai] - name: Install Python(macOS) Dependencies if: startsWith(matrix.os, 'macos') diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 7df48cf961..20904e9720 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -28,7 +28,7 @@ jobs: python -m pip install --upgrade pip pip install pyqt5 pip install pyinstaller - pip install -e .[macos,battery,cookies,ytdl] + pip install -e .[macos,battery,cookies,ytdl,ai] - name: Install libmpv run: | brew install mpv diff --git a/.github/workflows/win-release.yml b/.github/workflows/win-release.yml index 14ecf43aaa..a0152c763e 100644 --- a/.github/workflows/win-release.yml +++ b/.github/workflows/win-release.yml @@ -22,7 +22,7 @@ jobs: pip install pyqt5 pip install pyinstaller pip install pyinstaller-versionfile - pip install -e .[win32,battery,cookies,ytdl] + pip install -e .[win32,battery,cookies,ytdl,ai] - name: Download mpv-1.dll run: | choco install curl diff --git a/feeluown/ai.py b/feeluown/ai.py new file mode 100644 index 0000000000..8f45c45260 --- /dev/null +++ b/feeluown/ai.py @@ -0,0 +1,37 @@ +import asyncio +import socket + +from openai import AsyncOpenAI + +from feeluown.utils.aio import run_afn + + +async def a_handle_stream(stream): + rsock, wsock = socket.socketpair() + rr, rw = await asyncio.open_connection(sock=rsock) + _, ww = await asyncio.open_connection(sock=wsock) + + async def write_task(): + async for chunk in stream: + content = chunk.choices[0].delta.content or '' + ww.write(content.encode('utf-8')) + ww.write_eof() + await ww.drain() + ww.close() + await ww.wait_closed() + + task = run_afn(write_task) + return rr, rw, task + + +class AI: + def __init__(self, base_url, api_key, model): + self.base_url = base_url + self.api_key = api_key + self.model = model + + def get_async_client(self): + return AsyncOpenAI( + base_url=self.base_url, + api_key=self.api_key, + ) diff --git a/feeluown/app/app.py b/feeluown/app/app.py index 316c7f48d1..19277f4f66 100644 --- a/feeluown/app/app.py +++ b/feeluown/app/app.py @@ -21,7 +21,6 @@ from .mode import AppMode - logger = logging.getLogger(__name__) @@ -51,9 +50,29 @@ def __init__(self, args, config, **kwargs): 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) + self.library = Library( + config.PROVIDERS_STANDBY, + config.ENABLE_AI_STANDBY_MATCHER + ) + self.ai = None + try: + from feeluown.ai import AI + except ImportError as e: + logger.warning(f"AI is not available, err: {e}") + else: + if (config.OPENAI_API_BASEURL and + config.OPENAI_API_KEY and + config.OPENAI_MODEL): + self.ai = AI( + config.OPENAI_API_BASEURL, + config.OPENAI_API_KEY, + config.OPENAI_MODEL, + ) + self.library.setup_ai(self.ai) + else: + logger.warning("AI is not available, no valid settings") + if config.ENABLE_YTDL_AS_MEDIA_PROVIDER: try: self.library.setup_ytdl(rules=config.YTDL_RULES) diff --git a/feeluown/app/config.py b/feeluown/app/config.py index 3550e56a34..f1ff0b4132 100644 --- a/feeluown/app/config.py +++ b/feeluown/app/config.py @@ -86,6 +86,7 @@ def create_config() -> Config: ) 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('ENABLE_AI_STANDBY_MATCHER', type_=bool, default=True, desc='') config.deffield( 'AI_RADIO_PROMPT', type_=str, @@ -96,7 +97,7 @@ def create_config() -> Config: 1. 不要推荐与用户播放列表中一模一样的歌曲。不要推荐用户不喜欢的歌曲。不要重复推荐。 2. 你返回的内容只应该有 JSON,其它信息都不需要。也不要用 markdown 格式返回。 3. 你推荐的歌曲需要使用类似这样的 JSON 格式 - [{"title": "xxx", "artists_name": "yyy", "description": "推荐理由"}] + [{"title": "xxx", "artists": ["yyy", "zzz"], "description": "推荐理由"}] ''', desc='AI 电台功能的提示词' ) diff --git a/feeluown/gui/drawers.py b/feeluown/gui/drawers.py index d5a3c7ba15..f976fff11a 100644 --- a/feeluown/gui/drawers.py +++ b/feeluown/gui/drawers.py @@ -1,14 +1,17 @@ import math +import random from typing import Optional -from PyQt5.QtCore import Qt, QRect, QPoint, QPointF +from PyQt5.QtCore import Qt, QRect, QPoint, QPointF, QRectF from PyQt5.QtGui import ( QPainter, QBrush, QPixmap, QImage, QColor, QPolygonF, QPalette, QPainterPath, QGuiApplication, ) from PyQt5.QtWidgets import QWidget -from feeluown.gui.helpers import random_solarized_color, painter_save, IS_MACOS +from feeluown.gui.helpers import ( + random_solarized_color, painter_save, IS_MACOS, SOLARIZED_COLORS, +) class SizedPixmapDrawer: @@ -18,6 +21,7 @@ class SizedPixmapDrawer: Note that if device_pixel_ratio is not properly set, the drawed image quality may be poor. """ + def __init__(self, img: Optional[QImage], rect: QRect, radius: int = 0): self._rect = rect self._img_old_width = rect.width() @@ -103,6 +107,7 @@ class PixmapDrawer(SizedPixmapDrawer): TODO: rename this drawer to WidgetPixmapDrawer? """ + def __init__(self, img, widget: QWidget, radius: int = 0): """ :param widget: a object which has width() and height() method. @@ -147,16 +152,16 @@ def draw(self, painter: QPainter): # Draw body. x, y = self._padding, self._length // 2 width, height = self._length // 2, self._length // 2 - painter.drawArc(x, y, width, height, 0, 60*16) - painter.drawArc(x, y, width, height, 120*16, 60*16) + painter.drawArc(x, y, width, height, 0, 60 * 16) + painter.drawArc(x, y, width, height, 120 * 16, 60 * 16) class PlusIconDrawer: def __init__(self, length, padding): - self.top = QPoint(length//2, padding) - self.bottom = QPoint(length//2, length - padding) - self.left = QPoint(padding, length//2) - self.right = QPoint(length-padding, length//2) + self.top = QPoint(length // 2, padding) + self.bottom = QPoint(length // 2, length - padding) + self.left = QPoint(padding, length // 2) + self.right = QPoint(length - padding, length // 2) def draw(self, painter): pen = painter.pen() @@ -208,7 +213,7 @@ def set_direction(self, direction): right = QPointF(real_padding + diameter, half) d60 = diameter / 2 * 0.87 # sin60 - d30 = diameter / 2 / 2 # sin30 + d30 = diameter / 2 / 2 # sin30 if direction in ('left', 'right'): left_x = half - d30 @@ -247,14 +252,81 @@ def draw(self, painter): painter.drawPolygon(self.triangle) +class AIIconDrawer: + def __init__(self, length, padding, colorful=False): + + sr = length / 12 # small circle radius + sd = sr * 2 + + half = length / 2 + diameter = length - 2 * padding - sd + real_padding = (length - diameter) / 2 + d60 = diameter / 2 * 0.87 # sin60 + d30 = diameter / 2 / 2 # sin30 + left_x = half - d60 + bottom_y = half + d30 + right_x = half + d60 + + self._center_rect = QRectF(real_padding, real_padding, diameter, diameter) + self._top_circle = QRectF(half - sr, padding, sd, sd) + self._left_circle = QRectF(left_x - sr, bottom_y - sr, sd, sd) + self._right_circle = QRectF(right_x - sr, bottom_y - sr, sd, sd) + + self.colorful = colorful + self._colors = [QColor(e) for e in SOLARIZED_COLORS.values()] + self._colors_count = len(self._colors) + + def draw(self, painter, palette): + if self.colorful: + self._draw_colorful(painter, palette) + else: + self._draw_bw(painter, palette) + + def _draw_bw(self, painter, palette): + pen = painter.pen() + pen.setWidthF(1.5) + painter.setPen(pen) + with painter_save(painter): + painter.drawEllipse(self._center_rect) + painter.setBrush(palette.color(QPalette.Window)) + painter.drawEllipse(self._top_circle) + painter.drawEllipse(self._left_circle) + painter.drawEllipse(self._right_circle) + + def _draw_colorful(self, painter, palette): + pen = painter.pen() + pen.setWidthF(1.5) + pen.setColor(self._colors[random.randint(0, self._colors_count - 1)]) + painter.setPen(pen) + with painter_save(painter): + start_alen = 120 * 16 + pen.setColor(self._colors[5]) + painter.setPen(pen) + painter.drawArc(self._center_rect, 0, start_alen) + pen.setColor(self._colors[1]) + painter.setPen(pen) + painter.drawArc(self._center_rect, start_alen, start_alen) + pen.setColor(self._colors[4]) + painter.setPen(pen) + painter.drawArc(self._center_rect, start_alen * 2, start_alen) + + painter.setPen(Qt.NoPen) + painter.setBrush(self._colors[5]) + painter.drawEllipse(self._top_circle) + painter.setBrush(self._colors[1]) + painter.drawEllipse(self._left_circle) + painter.setBrush(self._colors[4]) + painter.drawEllipse(self._right_circle) + + class HomeIconDrawer: def __init__(self, length, padding): icon_length = length diff = 1 # root/body width diff h_padding = v_padding = padding - body_left_x = h_padding + diff*2 - body_right_x = icon_length - h_padding - diff*2 + body_left_x = h_padding + diff * 2 + body_right_x = icon_length - h_padding - diff * 2 body_top_x = icon_length // 2 self._roof = QPoint(icon_length // 2, v_padding) @@ -296,7 +368,7 @@ def paint(self, painter: QPainter): class RankIconDrawer: def __init__(self, length, padding): - body = length - 2*padding + body = length - 2 * padding body_2 = body // 2 body_8 = body // 8 body_3 = body // 3 @@ -324,8 +396,7 @@ def paint(self, painter: QPainter): class StarIconDrawer: def __init__(self, length, padding): - - radius_outer = (length - 2*padding)//2 + radius_outer = (length - 2 * padding) // 2 length_half = length // 2 radius_inner = radius_outer // 2 center = QPointF(length_half, length_half) @@ -339,8 +410,8 @@ def __init__(self, length, padding): ) self._star_polygon.append(outer_point) inner_point = center + QPointF( - radius_inner * math.cos(angle + math.pi/5), - -radius_inner * math.sin(angle + math.pi/5) + radius_inner * math.cos(angle + math.pi / 5), + -radius_inner * math.sin(angle + math.pi / 5) ) self._star_polygon.append(inner_point) angle += 2 * math.pi / 5 @@ -407,9 +478,9 @@ def draw(self, painter: QPainter, palette: QPalette): disabled_lines = () elif self._volume >= 33: lines = (self._line2, self._line3) - disabled_lines = (self._line1, ) + disabled_lines = (self._line1,) elif self._volume > 0: - lines = (self._line3, ) + lines = (self._line3,) disabled_lines = (self._line1, self._line2) else: lines = () @@ -493,6 +564,6 @@ def paint(self, painter: QPainter): font.setPixelSize(width - 4) else: # -1 works well on KDE when length is in range(30, 200) - font.setPixelSize(width - (self._length//20)) + font.setPixelSize(width - (self._length // 20)) painter.setFont(font) painter.drawText(0, 0, width, width, Qt.AlignHCenter | Qt.AlignVCenter, self._emoji) diff --git a/feeluown/gui/ui.py b/feeluown/gui/ui.py index 75bbcd9564..8c1199c633 100644 --- a/feeluown/gui/ui.py +++ b/feeluown/gui/ui.py @@ -27,8 +27,18 @@ def __init__(self, app): self._splitter = QSplitter(app) # Create widgets that don't rely on other widgets first. + try: + from feeluown.gui.uimain.ai_chat import AIChatOverlay + except ImportError as e: + logger.warning(f'AIChatOverlay is not available: {e}') + self.ai_chat_overlay = None + else: + self.ai_chat_overlay = AIChatOverlay(app, parent=app) + self.ai_chat_overlay.hide() self.lyric_window = LyricWindow(self._app) self.lyric_window.hide() + self.playlist_overlay = PlaylistOverlay(app, parent=app) + self.nowplaying_overlay = NowplayingOverlay(app, parent=app) # NOTE: 以位置命名的部件应该只用来组织界面布局,不要 # 给其添加任何功能性的函数 @@ -39,8 +49,6 @@ def __init__(self, app): self.page_view = self.right_panel = RightPanel(self._app, self._splitter) self.toolbar = self.bottom_panel = self.right_panel.bottom_panel self.mpv_widget = MpvOpenGLWidget(self._app) - self.playlist_overlay = PlaylistOverlay(app, parent=app) - self.nowplaying_overlay = NowplayingOverlay(app, parent=app) # alias self.magicbox = self.bottom_panel.magicbox diff --git a/feeluown/gui/uimain/ai_chat.py b/feeluown/gui/uimain/ai_chat.py new file mode 100644 index 0000000000..5296b2681b --- /dev/null +++ b/feeluown/gui/uimain/ai_chat.py @@ -0,0 +1,264 @@ +import json +import logging +from typing import TYPE_CHECKING, cast, List +from dataclasses import dataclass + +from openai import AsyncOpenAI +from PyQt5.QtCore import QEvent, QSize, Qt +from PyQt5.QtGui import QResizeEvent, QColor, QPainter +from PyQt5.QtWidgets import ( + QHBoxLayout, QVBoxLayout, QWidget, QLabel, QScrollArea, QPlainTextEdit, + QFrame, +) + +from feeluown.ai import a_handle_stream +from feeluown.utils.aio import run_afn_ref +from feeluown.library import fmt_artists_names +from feeluown.library.text2song import create_dummy_brief_song +from feeluown.gui.helpers import esc_hide_widget +from feeluown.gui.widgets.textbtn import TextButton +from feeluown.gui.widgets.header import MidHeader + + +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp + +logger = logging.getLogger(__name__) + + +QUERY_PROMPT = '''你是一个音乐播放器助手。''' +EXTRACT_PROMPT = '''\ +提取歌曲信息,歌手名为空的话,你需要补全,每首歌一行 JSON,用类似下面这样的格式返回 + {"title": "t1", "artists": ["a1", "a11"], "description": "推荐理由1"} + {"title": "t2", "artists": ["a11"], "description": "推荐理由2"} + +注意,你返回的内容只应该有几行 JSON,其它信息都不需要。也不要用 markdown 格式返回。 +''' + + +@dataclass +class ChatContext: + client: AsyncOpenAI + messages: List + + +class AIChatOverlay(QWidget): + def __init__(self, app: 'GuiApp', parent=None): + super().__init__(parent=parent) + self._app = app + + self.body = Body(app, self) + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(100, 80, 100, 80) + self._layout.addWidget(self.body) + self.setFocusPolicy(Qt.ClickFocus) + # Add ClickFocus for the body so that when Overlay will not + # get focus when user click the body. + self.body.setFocusPolicy(Qt.ClickFocus) + esc_hide_widget(self) + + def paintEvent(self, a0): + painter = QPainter(self) + painter.fillRect(self.rect(), QColor(0, 0, 0, 100)) + + def showEvent(self, e): + self.resize(self._app.size()) + super().showEvent(e) + self.raise_() + + def eventFilter(self, obj, event): + if self.isVisible() and obj is self._app and event.type() == QEvent.Resize: + event = cast(QResizeEvent, event) + self.resize(event.size()) + return False + + def focusInEvent(self, event): + self.hide() + super().focusInEvent(event) + + +class Body(QWidget): + def __init__(self, app: 'GuiApp', parent=None): + super().__init__(parent=parent) + self._app = app + + self._scrollarea = QScrollArea(self) + self._scrollarea.setFrameShape(QFrame.NoFrame) + self._editor = QPlainTextEdit(self) + self._editor.setPlaceholderText( + '这里可以填写一些歌曲相关的信息,然后配合功能按键来自动提取歌曲。\n\n' + '注:暂时还不支持对话,欢迎 PR 啦 ~' + ) + self._editor.setFrameShape(QFrame.NoFrame) + self._scrollarea.setWidget(self._editor) + self._msg_label = QLabel(self) + self._hide_btn = TextButton('关闭窗口', self) + self._extract_and_play_btn = TextButton('提取歌曲并播放', self) + self._extract_10_and_play_btn = TextButton('提取10首并播放', self) + self._welcome_pr = TextButton('来,一起调教 AI') + + self.setup_ui() + self._hide_btn.clicked.connect(self._hide) + self._extract_and_play_btn.clicked.connect( + lambda: run_afn_ref(self.extract_and_play)) + self._extract_10_and_play_btn.clicked.connect( + lambda: run_afn_ref(self.extract_10_and_play)) + self._welcome_pr.clicked.connect(lambda: self.set_msg('那来个 PR 呗 :)')) + + self._chat_context = None + self.setAutoFillBackground(True) + + def setup_ui(self): + self._msg_label.setWordWrap(True) + self._scrollarea.setWidgetResizable(True) + self._app.installEventFilter(self) + self._msg_label.setTextFormat(Qt.RichText) + + self._root_layout = QVBoxLayout(self) + self._layout = QHBoxLayout() + self._v_layout = QVBoxLayout() + self._btn_layout = QVBoxLayout() + + self._root_layout.addWidget(MidHeader('AI 助手')) + self._root_layout.addLayout(self._layout) + self._layout.addStretch(0) + self._layout.addLayout(self._v_layout) + self._layout.setStretch(1, 1) + self._layout.addLayout(self._btn_layout) + self._layout.addStretch(0) + self._root_layout.setContentsMargins(10, 10, 10, 10) + self._root_layout.setSpacing(10) + + self._v_layout.addWidget(self._scrollarea) + self._v_layout.addWidget(self._msg_label) + self._btn_layout.addWidget(self._extract_and_play_btn) + self._btn_layout.addWidget(self._extract_10_and_play_btn) + self._btn_layout.addWidget(self._hide_btn) + self._btn_layout.addStretch(0) + self._btn_layout.addWidget(self._welcome_pr) + + async def exec_user_query(self, query): + self.set_msg('等待 AI 返回中...', level='hint') + client = self._app.ai.get_async_client() + messages = [ + {'role': 'system', 'content': QUERY_PROMPT}, + {'role': 'user', 'content': query} + ] + self._chat_context = ChatContext(client, messages) + try: + stream = await client.chat.completions.create( + model=self._app.config.OPENAI_MODEL, + messages=messages, + stream=True, + ) + except: # noqa + self._app.show_msg('OpenAI 接口调用失败') + logger.exception('OpenAI API request failed') + else: + content = '' + async for chunk in stream: + self.set_msg('AI 返回中...', level='hint') + content += chunk.choices[0].delta.content or '' + self.show_chat_message(content) + assistant_message = {"role": "assistant", "content": content} + self._chat_context.messages.append(assistant_message) + self.set_msg('AI 内容返回结束', level='hint') + + def show_chat_message(self, text): + self._editor.setPlainText(text) + + def set_msg(self, text, level='hint'): + if level == 'hint': + color = 'green' + elif level == 'warn': + color = 'yellow' + else: # err + color = 'red' + self._msg_label.setText(f'{text}') + + async def extract_and_play(self): + await self._extract_and_play(EXTRACT_PROMPT) + + async def extract_10_and_play(self): + await self._extract_and_play(f'{EXTRACT_PROMPT}\n随机提取最多10首即可') + + async def _extract_and_play(self, extract_prompt): + if self._chat_context is None: + self._chat_context = ChatContext( + client=self._app.ai.get_async_client(), + messages=[ + {'role': 'system', 'content': extract_prompt}, + {'role': 'user', 'content': self._editor.toPlainText()}, + ], + ) + else: + message = {'role': 'user', 'content': extract_prompt} + self._chat_context.messages.append(message) + self.set_msg('正在让 AI 解析歌曲信息,这可能会花费一些时间...') + stream = await self._chat_context.client.chat.completions.create( + model=self._app.config.OPENAI_MODEL, + messages=self._chat_context.messages, + stream=True, + ) + + rr, rw, wtask = await a_handle_stream(stream) + ok_count = 0 + fail_count = 0 + while True: + try: + line = await rr.readline() + line = line.decode('utf-8') + logger.debug(f'read a line: {line}') + if not line: + self.set_msg(f'解析结束,成功解析{ok_count}首歌曲,失败{fail_count}首歌。', + level='hint') + break + try: + jline = json.loads(line) + title, artists = jline['title'], jline['artists'] + artists_name = fmt_artists_names(artists) + except: # noqa + fail_count += 1 + logger.exception(f'failed to parse a line: {line}') + self.set_msg(f'成功解析{ok_count}首歌曲,失败{fail_count}首歌', + level='yellow') + else: + song = create_dummy_brief_song(title, artists_name) + ok_count += 1 + self.set_msg(f'成功解析{ok_count}首歌曲,失败{fail_count}首歌', + level='hint') + self._app.playlist.add(song) + if ok_count == 1: + self._app.playlist.play_model(song) + except: # noqa + logger.exception('extract and play failed') + break + + await wtask + rw.close() + await rw.wait_closed() + + def _hide(self): + self._chat_context = None + self.parent().hide() + + def hide(self): + self._hide() + super().hide() + + +if __name__ == '__main__': + import os + from PyQt5.QtWidgets import QWidget + from feeluown.gui.debug import simple_layout, mock_app + + with simple_layout(theme='dark') as layout, mock_app() as app: + app.size.return_value = QSize(600, 400) + app.config.OPENAI_API_KEY = os.environ.get('DEEPSEEK_API_KEY') + app.config.OPENAI_API_BASEURL = 'https://api.deepseek.com' + app.config.OPENAI_MODEL = 'deepseek-chat' + widget = AIChatOverlay(app) + widget.resize(600, 400) + layout.addWidget(widget) + widget.show() + widget.body.show_chat_message('Hello, feeluown!' * 100) diff --git a/feeluown/gui/uimain/sidebar.py b/feeluown/gui/uimain/sidebar.py index e8e5e3a69a..153055a9af 100644 --- a/feeluown/gui/uimain/sidebar.py +++ b/feeluown/gui/uimain/sidebar.py @@ -11,7 +11,7 @@ from feeluown.utils.reader import create_reader, Reader from feeluown.utils.aio import run_fn from feeluown.gui.components import CollectionListView -from feeluown.gui.widgets import HomeButton +from feeluown.gui.widgets import HomeButton, AIButton from feeluown.gui.widgets.separator import Separator from .provider_bar import ProviderBar, ListViewContainer as LVC @@ -63,12 +63,15 @@ def __init__(self, app: 'GuiApp', parent=None): self._app = app self.home_btn = HomeButton(height=30, parent=self) + self.ai_btn = AIButton(height=30, padding=0.2, parent=self) self.collections_header = QLabel('本地收藏集', self) - self.collections_header.setToolTip('我们可以在本地建立『收藏集』来收藏自己喜欢的音乐资源\n\n' - '每个收藏集都以一个独立 .fuo 文件的存在,' - '将鼠标悬浮在收藏集上,可以查看文件所在路径。\n' - '新建 fuo 文件,则可以新建收藏集,文件名即是收藏集的名字。\n\n' - '手动编辑 fuo 文件即可编辑收藏集中的音乐资源,也可以在界面上拖拽来增删歌曲。') + self.collections_header.setToolTip( + '我们可以在本地建立『收藏集』来收藏自己喜欢的音乐资源\n\n' + '每个收藏集都以一个独立 .fuo 文件的存在,' + '将鼠标悬浮在收藏集上,可以查看文件所在路径。\n' + '新建 fuo 文件,则可以新建收藏集,文件名即是收藏集的名字。\n\n' + '手动编辑 fuo 文件即可编辑收藏集中的音乐资源,也可以在界面上拖拽来增删歌曲。' + ) self.collections_view = CollectionListView(self._app) self.collections_con = LVC(self.collections_header, self.collections_view) self._top_separator = Separator(self._app) @@ -84,6 +87,7 @@ def __init__(self, app: 'GuiApp', parent=None): self._layout.setSpacing(0) self._layout.setContentsMargins(16, 10, 16, 0) self._layout.addWidget(self.home_btn) + self._layout.addWidget(self.ai_btn) self._layout.addWidget(self.collections_con) self._layout.addWidget(self._top_separator) self._layout.addWidget(self.provider_bar) @@ -103,9 +107,18 @@ def __init__(self, app: 'GuiApp', parent=None): lambda: self._app.browser.goto(page='/homepage')) else: self.home_btn.clicked.connect(self.show_library) + self.ai_btn.clicked.connect(self._app.ui.ai_chat_overlay.show) + if self._app.ai is None: + self.ai_btn.setDisabled(True) + self.ai_btn.setToolTip( + '你需要安装 Python 三方库 openai,并且配置如下配置项,你就可以使用 AI 助手了\n' + 'config.OPENAI_API_KEY = "sk-xxx"\n' + 'config.OPENAI_API_BASEURL = "http://xxx"\n' + 'config.OPENAI_API_MODEL = "model name"\n' + ) def _toggle_top_layout(self, checked): - widgets = [self._top_separator, self.collections_con, self.home_btn] + widgets = [self._top_separator, self.collections_con, self.home_btn, self.ai_btn] if checked: self.provider_bar.fold_top_btn.set_direction('down') for w in widgets: diff --git a/feeluown/gui/widgets/__init__.py b/feeluown/gui/widgets/__init__.py index 3e31f09633..ed2d4e027a 100644 --- a/feeluown/gui/widgets/__init__.py +++ b/feeluown/gui/widgets/__init__.py @@ -6,5 +6,5 @@ PlusButton, TriagleButton, DiscoveryButton, SelfPaintAbstractIconTextButton, CalendarButton, RankButton, StarButton, PlayPauseButton, PlayNextButton, PlayPreviousButton, - MVButton, VolumeButton, HotButton, EmojiButton + MVButton, VolumeButton, HotButton, EmojiButton, AIButton ) diff --git a/feeluown/gui/widgets/magicbox.py b/feeluown/gui/widgets/magicbox.py index f834929a92..ebf3682ba8 100644 --- a/feeluown/gui/widgets/magicbox.py +++ b/feeluown/gui/widgets/magicbox.py @@ -1,10 +1,16 @@ import io import sys +from typing import TYPE_CHECKING from PyQt5.QtCore import Qt, QTimer, pyqtSignal from PyQt5.QtWidgets import QLineEdit, QSizePolicy +from feeluown.library.text2song import create_dummy_brief_song from feeluown.fuoexec import fuoexec +from feeluown.utils.aio import run_afn + +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp _KeyPrefix = 'search_' # local storage key prefix KeySourceIn = _KeyPrefix + 'source_in' @@ -20,15 +26,19 @@ class MagicBox(QLineEdit): # this filter signal is designed for table (songs_table & albums_table) filter_text_changed = pyqtSignal(str) - def __init__(self, app, parent=None): + def __init__(self, app: 'GuiApp', parent=None): super().__init__(parent) self._app = app self.setPlaceholderText('搜索歌曲、歌手、专辑、用户') - self.setToolTip('直接输入文字可以进行过滤,按 Enter 可以搜索\n' - '输入 >>> 前缀之后,可以执行 Python 代码\n' - '输入 # 前缀之后,可以过滤表格内容\n' - '输入 > 前缀可以执行 fuo 命令(未实现,欢迎 PR)') + self.setToolTip( + '直接输入文字可以进行过滤,按 Enter 可以搜索\n' + '输入 >>> 前缀之后,可以执行 Python 代码\n' + '输入 “==> 执迷不悔 | 王菲”,可以直接播放歌曲\n' + '输入 “=== 下雨天听点啥?”,可以和 AI 互动\n' + '输入 # 前缀之后,可以过滤表格内容\n' + '输入 > 前缀可以执行 fuo 命令(未实现,欢迎 PR)' + ) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.setFixedHeight(32) self.setFrame(False) @@ -101,6 +111,32 @@ def __on_return_pressed(self): text = self.text() if text.startswith('>>> '): self._exec_code(text[4:]) + elif text.startswith('---') or text.startswith('==='): + if self._app.ui.ai_chat_overlay is not None: + body = text[4:] if len(text) > 4 else '' + if body: + run_afn(self._app.ui.ai_chat_overlay.body.exec_user_query, body) + self._app.ui.ai_chat_overlay.show() + else: + self._app.show_msg('AI 聊天功能不可用') + elif text.startswith('--> ') or text.startswith('==> ') \ + or text.startswith('--》') or text.startswith('==》'): + body = text[4:] + if not body: + return + delimiters = ('|', '-') + title = artists_name = '' + for delimiter in delimiters: + parts = body.split(delimiter) + if len(parts) == 2: + title, artists_name = parts + break + if title and artists_name: + song = create_dummy_brief_song(title.strip(), artists_name.strip()) + self._app.playlist.play_model(song) + self._app.show_msg(f'尝试播放:{song}') + else: + self._app.show_msg('你输入的内容需要符合格式:“歌曲标题 | 歌手名”') else: local_storage = self._app.browser.local_storage type_ = local_storage.get(KeyType) diff --git a/feeluown/gui/widgets/selfpaint_btn.py b/feeluown/gui/widgets/selfpaint_btn.py index 5cb2486448..909ab29156 100644 --- a/feeluown/gui/widgets/selfpaint_btn.py +++ b/feeluown/gui/widgets/selfpaint_btn.py @@ -13,6 +13,7 @@ SearchIconDrawer, FireIconDrawer, EmojiIconDrawer, + AIIconDrawer, ) from feeluown.gui.helpers import darker_or_lighter, painter_save @@ -267,6 +268,16 @@ def draw_icon(self, painter): painter.drawPoint(QPoint(self._padding, center)) +class AIButton(SelfPaintAbstractIconTextButton): + + def __init__(self, *args, **kwargs): + super().__init__('AI', *args, **kwargs) + self.ai_icon = AIIconDrawer(self.height(), self._padding) + + def draw_icon(self, painter): + self.ai_icon.draw(painter, self.palette()) + + class DiscoveryButton(SelfPaintAbstractIconTextButton): def __init__(self, text='发现', **kwargs): @@ -572,6 +583,7 @@ def paintEvent(self, _): l3.addWidget(HotButton(height=length)) l3.addWidget(HomeButton(height=length)) + l3.addWidget(AIButton(height=length)) l3.addWidget(DiscoveryButton(height=length)) l3.addWidget(RankButton(height=length)) l3.addWidget(StarButton(height=length)) diff --git a/feeluown/library/ai_standby.py b/feeluown/library/ai_standby.py new file mode 100644 index 0000000000..f03f01195a --- /dev/null +++ b/feeluown/library/ai_standby.py @@ -0,0 +1,248 @@ +import asyncio +import logging +import json +from typing import List, TYPE_CHECKING + +from feeluown.ai import a_handle_stream +from feeluown.utils.aio import run_afn, as_completed + +if TYPE_CHECKING: + from feeluown.ai import AI + from feeluown.library import BriefSongModel + +logger = logging.getLogger(__name__) + + +class AIStandbyMatcher: + # 调试的使用,可以考虑把 reason 字段也带上。 + # {"song_id": "xxx", "score": 100, "reason": ""} + # {"song_id": "yyy", "score": 95, "reason": ""} + # + # {"song_id": "xxx", "score": 100} + # {"song_id": "yyy", "score": 95} + STANDBY_MATCH_PROMPT = '''\ +你是一个音乐播放器助手。你根据“匹配度”帮助用户对搜索候选项进行排序。\ +判断匹配度的时候,简体和繁体、英文和中文这些因素都可以忽略,你可以智能转换,然后看是否匹配。\ +你要重点考虑原唱这个因素,类似“重制、翻唱、非原创”的歌曲,排序都应该靠后。\ +排序完之后,你需要用类似下面的格式来返回,每行一个 JSON,你返回的内容不能包含其它内容 + + {"song_id": "xxx", "score": 100} + {"song_id": "yyy", "score": 95} +''' + + def __init__(self, ai: 'AI', a_prepare_media, min_score, audio_select_policy): + self.ai = ai + self._prepare_media = a_prepare_media + + self.min_score = min_score + self.audio_select_policy = audio_select_policy + + # during runtime + self.fetch_media_tasks = {} + self.song_by_source = {} + + async def match(self, song: 'BriefSongModel', standby_list: List['BriefSongModel']): + sys_msg = {'role': 'system', 'content': self.STANDBY_MATCH_PROMPT} + user_msg = { + 'role': 'user', + 'content': (f'我搜索了 `{self.song_as_jsonline(song)}`\n' + '应用返回的候选项如下:\n' + f'{self.standby_list_as_jsonlines(standby_list)}') + } + logger.info(f"Try to find {song} standby, user msg: {user_msg['content']}") + client = self.ai.get_async_client() + try: + stream = await client.chat.completions.create( + model=self.ai.model, + messages=[sys_msg, user_msg], + stream=True, + ) + except: # noqa + logger.exception('OpenAI API request failed') + return [] + + rr, rw, wtask = await a_handle_stream(stream) + source_media_pair = None + while True: + try: + lineb = await rr.readline() + except: # noqa + logger.exception('readline failed') + break + if not lineb: + break + + source_media_pair = await self.try_get_source_media_pair() + if source_media_pair: + logger.info(f'Standby from ${source_media_pair[0]} is available') + await stream.close() + break + + line = lineb.decode('utf-8') + logger.debug(f"{song} standby: {line}") + try: + song_json = json.loads(line) + except json.JSONDecodeError: + logger.debug(f'invalid json line: {line}') + continue + if song_json['score'] < self.min_score: + continue + try: + source, identifier = song_json['song_id'].split('___') + except ValueError: + logger.error(f'AI returns a invalid response: {song_json}') + break + if source in self.song_by_source: # Only use the first song for each source. + continue + for standby in standby_list: + if standby.source == source and standby.identifier == identifier: + self.song_by_source[source] = standby + task = run_afn( + self._prepare_media, standby, self.audio_select_policy) + self.fetch_media_tasks[source] = task + + try: + await wtask + except: # noqa + if not source_media_pair: + logger.exception('Stream consumer error') + rw.close() + await rw.wait_closed() + + if not source_media_pair: + source_media_pair = await self.wait_and_get_source_media_pair() + if source_media_pair: + self.cancel_fetch_media_tasks() + standby = self.song_by_source[source_media_pair[0]] + media = source_media_pair[1] + logger.debug(f'Standby matched for {song}') + return [(standby, media)] + logger.debug(f'No standby matched for {song}') + return [] + + async def try_get_source_media_pair(self): + source_media_pair_ = None + for source, task in self.fetch_media_tasks.items(): + if task.done(): + _media = task.result() + if _media is not None: + source_media_pair_ = (source, _media) + break + return source_media_pair_ + + async def wait_and_get_source_media_pair(self): + fs = self.fetch_media_tasks.values() + # TODO: add timeout + for future in as_completed(fs, timeout=None): + try: + media = await future + except: # noqa + logger.exception('fetch media task failed') + continue + else: + # When a provider does not implement search method, it returns None. + source = None + for source, task in self.fetch_media_tasks.items(): + if future == task: + source = source + break + if media is not None: + return (source, media) + else: + logger.debug('No media available in source:${source}') + return None + + def cancel_fetch_media_tasks(self): + for source, task in self.fetch_media_tasks.items(): + if not task.done(): + task.cancel() + + @classmethod + def song_as_jsonline(cls, song: 'BriefSongModel'): + js = { + 'title': song.title, + 'artists_name': song.artists_name, + } + if song.album_name: + js['album_name'] = song.album_name, + if song.duration_ms: + js['duration_ms'] = song.duration_ms, + return json.dumps(js) + + @classmethod + def standby_list_as_jsonlines(cls, songs: List['BriefSongModel']): + lines = [] + for song in songs: + js = { + 'song_id': f'{song.source}___{song.identifier}', + 'title': song.title, + 'artists_name': song.artists_name, + 'album_name': song.album_name, + 'duration_ms': song.duration_ms, + } + lines.append(json.dumps(js)) + return '\n'.join(lines) + + +# For debugging. +if __name__ == '__main__': + import os + + from feeluown.library import Library + from feeluown.library.uri import resolve, Resolver + from fuo_ytmusic.provider import provider as p1 + from fuo_qqmusic import provider as p2 + from fuo_netease import provider as p3 + + logging.basicConfig(level=logging.DEBUG) + + library = Library() + p1.setup_http_proxy('http://127.0.0.1:7890') + library.register(p1) + library.register(p2) + library.register(p3) + Resolver.library = library + library.setup_ai(AI( + # base_url='https://api.deepseek.com', + # api_key=os.environ.get('DEEPSEEK_API_KEY'), + # model='deepseek-chat', + base_url='https://ark.cn-beijing.volces.com/api/v3', + api_key=os.environ.get('ARK_API_KEY'), + model='ep-20250202091715-vwjw2', + # base_url='https://open.bigmodel.cn/api/paas/v4/', + # api_key=os.environ.get('GLM_API_KEY'), + # model='GLM-4-Air', + # api_key=os.environ.get('MOONSHOT_API_KEY'), + # base_url='https://api.moonshot.cn/v1', + # model='moonshot-v1-8k', + )) + standby_text = '''\ +fuo://qqmusic/songs/409175284 # "下雨天 - 鱼天邻制作" - 南拳妈妈 - "" - 00:51 +fuo://qqmusic/songs/463631892 # 下雨天 (DJ阿智版) - 南拳妈妈 & DJ阿智 - "" - 01:28 +fuo://qqmusic/songs/102697748 # 下雨天 - 南拳妈妈 - 优の良曲 南搞小孩 - 04:13 +fuo://ytmusic/songs/XkcKycMblaE # 下雨天 - 芝麻Mochi - 下雨天 - 04:26 +fuo://ytmusic/songs/F-YMyH74748 # 下雨天 - "" - 下雨天 - 04:14 +fuo://ytmusic/songs/BBe-Zwb7ElM # Rainy Day (下雨天) - Nan Quan Mama - 優的良曲南搞小孩 - 04:14 +fuo://netease/songs/1382202727 # 下雨天(翻自 南拳妈妈NQMM) - 33没事儿 - 翻唱 - 04:10 +fuo://netease/songs/2135170282 # 下雨天(Alqas 版) - Kk - 南拳妈妈-下雨天 - 03:26 +fuo://netease/songs/1905457762 # 下雨天(Cover 南拳妈妈) - 张贤静 - For you - 04:36 +''' + standby_list = [] + for line in standby_text.splitlines(): + l_nospace = line.strip() + if l_nospace: + standby_list.append(resolve(l_nospace)) + + print(standby_text) + + matcher = AIStandbyMatcher( + library.ai, library.a_song_prepare_media_no_exc, 60, '>>>') + + song = BriefSongModel( + source='dummy', + identifier='xxx', + title='下雨天', + artists_name='南拳妈妈' + ) + pair = asyncio.run(matcher.match(song, standby_list)) + print(pair) diff --git a/feeluown/library/library.py b/feeluown/library/library.py index acd2550830..fcdfd50e5f 100644 --- a/feeluown/library/library.py +++ b/feeluown/library/library.py @@ -1,36 +1,43 @@ # mypy: disable-error-code=type-abstract import logging import warnings +from collections import Counter from functools import partial from typing import Optional, TypeVar, List, TYPE_CHECKING from feeluown.media import Media from feeluown.utils.aio import run_fn, as_completed from feeluown.utils.dispatch import Signal -from .base import SearchType, ModelType -from .provider import Provider -from .excs import MediaNotFound, ProviderAlreadyExists, ModelNotFound, ResourceNotFound -from .flags import Flags as PF -from .models import ( +from feeluown.library.ai_standby import AIStandbyMatcher +from feeluown.library.base import SearchType, ModelType +from feeluown.library.provider import Provider +from feeluown.library.excs import ( + MediaNotFound, ProviderAlreadyExists, ModelNotFound, ResourceNotFound, +) +from feeluown.library.flags import Flags as PF +from feeluown.library.models import ( ModelFlags as MF, BaseModel, BriefVideoModel, BriefSongModel, SongModel, LyricModel, VideoModel, BriefAlbumModel, BriefArtistModel ) -from .model_state import ModelState -from .provider_protocol import ( +from feeluown.library.model_state import ModelState +from feeluown.library.provider_protocol import ( check_flag as check_flag_impl, SupportsSongLyric, SupportsSongMV, SupportsSongMultiQuality, SupportsVideoMultiQuality, SupportsSongWebUrl, SupportsVideoWebUrl, ) -from .similarity import get_standby_origin_similarity, FULL_SCORE +from feeluown.library.standby import ( + get_standby_score, + STANDBY_DEFAULT_MIN_SCORE, + STANDBY_FULL_SCORE, +) if TYPE_CHECKING: - from .ytdl import Ytdl - + from feeluown.ai import AI + from feeluown.library.ytdl import Ytdl logger = logging.getLogger(__name__) -MIN_SCORE = 5 T_p = TypeVar('T_p') @@ -41,7 +48,7 @@ def raise_(e): class Library: """Resource entrypoints.""" - def __init__(self, providers_standby=None): + def __init__(self, providers_standby=None, enable_ai_standby_matcher=True): """ :type app: feeluown.app.App @@ -49,15 +56,20 @@ def __init__(self, providers_standby=None): self._providers_standby = providers_standby self._providers = set() self.ytdl: Optional['Ytdl'] = None + self.ai: Optional['AI'] = None self.provider_added = Signal() # emit(AbstractProvider) self.provider_removed = Signal() # emit(AbstractProvider) + self.enable_ai_standby_matcher = enable_ai_standby_matcher def setup_ytdl(self, *args, **kwargs): from .ytdl import Ytdl self.ytdl = Ytdl(*args, **kwargs) + def setup_ai(self, ai): + self.ai = ai + def register(self, provider): """register provider @@ -148,66 +160,97 @@ async def a_search(self, keyword, source_in=None, timeout=None, if result is not None: yield 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): + async def a_song_prepare_media_no_exc(self, standby, policy): + media = None + try: + media = await run_fn(self.song_prepare_media, standby, policy) + except MediaNotFound as e: + logger.debug(f'standby media not found: {e}') + except: # noqa + logger.exception(f'get standby:{standby} media failed') + return media + + async def a_list_song_standby_v2( + self, song, audio_select_policy='>>>', source_in=None, + score_fn=None, min_score=STANDBY_DEFAULT_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 run_fn(self.song_prepare_media, standby, policy) - except MediaNotFound as e: - logger.debug(f'standby media not found: {e}') - 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 = get_standby_origin_similarity + score_fn = get_standby_score 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)] + song_media_list = [] # [(standby, media), (standby, media)] + top2_standby = [] 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: + for i, standby in enumerate(result.songs): + # HACK(cosven): I think the local provider should not be included, + # because the search algorithm of local provider is so bad. + if i < 2 and standby.source != 'local': + top2_standby.append(standby) score = score_fn(song, standby) - if score == FULL_SCORE: - media = await prepare_media(standby, audio_select_policy) + if score == STANDBY_FULL_SCORE: + media = await self.a_song_prepare_media_no_exc( + standby, + audio_select_policy + ) if media is None: continue - logger.debug(f'find full mark standby for song:{q}') + logger.info(f'Find full score 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)) - standby_pvd_id_set = {standby.source for standby, _ in standby_score_list} - logger.debug(f"find {len(standby_score_list)} similar songs " - f"from {','.join(standby_pvd_id_set)}") - # 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 + if standby_score_list: + standby_pvd_id_set = {standby.source for standby, _ in standby_score_list} + logger.info(f"Find {len(standby_score_list)} similar songs " + f"from {','.join(standby_pvd_id_set)}. Try to get a valid media") + max_per_source = 2 + standby_score_list_2 = [] + counter = Counter() + for s, score in standby_score_list: + if counter[s.source] >= max_per_source: + continue + counter[s.source] += 1 + standby_score_list_2.append((s, score)) + + assert len(standby_score_list_2) <= max_per_source * len(standby_pvd_id_set) + sorted_standby_score_list = sorted( + standby_score_list_2, + key=lambda song_score: song_score[1], + reverse=True, + ) + for standby, _ in sorted_standby_score_list: + # TODO: send multiple requests at a time. + media = await self.a_song_prepare_media_no_exc( + 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 + if self.enable_ai_standby_matcher and self.ai and top2_standby: + logger.info(f'Try to use AI to match standby for song {song}') + matcher = AIStandbyMatcher( + self.ai, self.a_song_prepare_media_no_exc, 60, audio_select_policy) + song_media_list = await matcher.match(song, top2_standby) + word = 'found a' if song_media_list else 'found no' + logger.info(f'AI {word} standby for song:{song}') + return song_media_list return song_media_list def check_flags(self, source: str, model_type: ModelType, flags: PF) -> bool: diff --git a/feeluown/library/similarity.py b/feeluown/library/similarity.py deleted file mode 100644 index 759a2cf930..0000000000 --- a/feeluown/library/similarity.py +++ /dev/null @@ -1,56 +0,0 @@ -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/standby.py b/feeluown/library/standby.py new file mode 100644 index 0000000000..7b05f139b4 --- /dev/null +++ b/feeluown/library/standby.py @@ -0,0 +1,51 @@ +from .models import SongModel + +STANDBY_DEFAULT_MIN_SCORE = 0.5 +STANDBY_FULL_SCORE = 1 + + +def get_standby_score(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) + + def get_duration(s): + return s.duration if isinstance(s, SongModel) else \ + duration_ms_to_duration(s.duration_ms) + + origin_duration = get_duration(origin) + + score = 0 + if not (origin.album_name and origin_duration): + score_dis = [4, 4, 1, 1] + else: + score_dis = [3, 2, 2, 3] + + if origin.artists_name == standby.artists_name: + score += score_dis[0] + if origin.title == standby.title: + score += score_dis[1] + # Only compare album_name when it is not empty. + if origin.album_name and origin.album_name == standby.album_name: + score += score_dis[2] + standby_duration = get_duration(standby) + if origin_duration and \ + abs(origin_duration - standby_duration) / max(origin_duration, 1) < 0.1: + score += score_dis[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 / 10 diff --git a/feeluown/library/text2song.py b/feeluown/library/text2song.py index 3accf26681..62d3d2d456 100644 --- a/feeluown/library/text2song.py +++ b/feeluown/library/text2song.py @@ -1,17 +1,28 @@ -import uuid import json -from .models import BriefSongModel, ModelState +from .models import BriefSongModel, ModelState, fmt_artists_names +from feeluown.utils.utils import elfhash class AnalyzeError(Exception): pass +def create_dummy_brief_song(title, artists_name): + identifier = elfhash(f'{title}-{artists_name}'.encode('utf-8')) + return BriefSongModel( + source='dummy', + identifier=identifier, + title=title, + artists_name=artists_name, + state=ModelState.not_exists, + ) + + def analyze_text(text): def json_fn(each): try: - return each['title'], each['artists_name'] + return each['title'], fmt_artists_names(each['artists']) except KeyError: return None @@ -51,13 +62,7 @@ def line_fn(line): 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, - ) + song = create_dummy_brief_song(title, artists_name) songs.append(song) else: err_count += 1 diff --git a/feeluown/player/ai_radio.py b/feeluown/player/ai_radio.py index 2c23a8dbf1..693d508a2d 100644 --- a/feeluown/player/ai_radio.py +++ b/feeluown/player/ai_radio.py @@ -91,10 +91,11 @@ def get_msg(self): # For debugging. if __name__ == '__main__': + import os from unittest.mock import MagicMock app = MagicMock() - app.config.OPENAI_API_KEY = 'xxx' + app.config.OPENAI_API_KEY = os.environ.get('DEEPSEEK_API_KEY', '') app.config.OPENAI_API_BASEURL = 'https://api.deepseek.com' app.config.OPENAI_MODEL = 'deepseek-chat' app.config.AI_RADIO_PROMPT = '''\ @@ -104,7 +105,7 @@ def get_msg(self): 1. 不要推荐与用户播放列表中一模一样的歌曲。不要推荐用户不喜欢的歌曲。不要重复推荐。 2. 你返回的内容只应该有 JSON,其它信息都不需要。也不要用 markdown 格式返回。 3. 你推荐的歌曲需要使用类似这样的 JSON 格式 - [{"title": "xxx", "artists_name": "yyy", "description": "推荐理由"}] + [{"title": "xxx", "artists": ["yyy", "zzz"], "description": "推荐理由"}] ''' radio = AIRadio(app) radio.get_msg = MagicMock(return_value=''' diff --git a/feeluown/utils/aio.py b/feeluown/utils/aio.py index 0dd5ff7dd3..0e8b004a50 100644 --- a/feeluown/utils/aio.py +++ b/feeluown/utils/aio.py @@ -39,6 +39,8 @@ #: wait_for is an alias of `asyncio.wait_for` wait_for = asyncio.wait_for +bg_tasks = set() + def run_in_executor(executor, func, *args): """alias for loop.run_in_executor""" @@ -54,6 +56,17 @@ def run_afn(afn, *args): return create_task(afn(*args)) +def run_afn_ref(afn, *args): + """Create a background task and keep a reference. + + .. versionadded:: 4.1.9 + """ + task = create_task(afn(*args)) + bg_tasks.add(task) + task.add_done_callback(bg_tasks.discard) + return task + + def run_fn(fn, *args): """Alias for run_in_executor with default executor diff --git a/setup.py b/setup.py index a1efcfe9c2..a22f404621 100644 --- a/setup.py +++ b/setup.py @@ -67,6 +67,9 @@ # https://github.com/BruceZhang1993/feeluown-bilibili 'feeluown-bilibili>=0.4.1', ], + 'ai': [ + 'openai>=1.50', + ], 'macOS': [ 'aionowplaying>=0.10', ], diff --git a/tests/library/test_similarity.py b/tests/library/test_standby.py similarity index 79% rename from tests/library/test_similarity.py rename to tests/library/test_standby.py index 94e03a9b7c..b4cb943bb5 100644 --- a/tests/library/test_similarity.py +++ b/tests/library/test_standby.py @@ -1,8 +1,11 @@ import random -from feeluown.library.similarity import get_standby_origin_similarity, FULL_SCORE +from feeluown.library.standby import ( + get_standby_score, + STANDBY_DEFAULT_MIN_SCORE, + STANDBY_FULL_SCORE, +) from feeluown.library import BriefSongModel -from feeluown.library.library import MIN_SCORE def test_get_standby_origin_similarity_1(): @@ -12,20 +15,28 @@ def test_get_standby_origin_similarity_1(): title='x', artists_name='y', ) - standby = BriefSongModel( + standby1 = BriefSongModel( source='', identifier='', title='z', artists_name='y', ) - # 对于上面这种情况,不应该匹配上。 - assert get_standby_origin_similarity(origin, standby) < MIN_SCORE + # Should not match + assert get_standby_score(origin, standby1) < STANDBY_DEFAULT_MIN_SCORE + standby2 = BriefSongModel( + source='', + identifier='', + title='x', + artists_name='y', + ) + # Should match + assert get_standby_score(origin, standby2) >= STANDBY_DEFAULT_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 + score_fn = get_standby_score def create_song(title, artists_name, album_name, duration_ms): return BriefSongModel(identifier=random.randint(0, 1000), @@ -56,7 +67,7 @@ def create_song(title, artists_name, album_name, duration_ms): # 字符串上一模一样,理应返回满分 song = create_song('我很想爱他', 'Twins', '八十块环游世界', '04:27') candidates = [create_song('我很想爱他', 'Twins', '八十块环游世界', '04:27')] - assert score_fn(song, candidates[0]) == FULL_SCORE + assert score_fn(song, candidates[0]) == STANDBY_FULL_SCORE # 根据人工判断,分数应该有 9 分,期望目标算法最起码不能忽略这首歌曲 song = create_song('很爱很爱你 (Live)', '刘若英', @@ -64,4 +75,4 @@ def create_song(title, artists_name, album_name, duration_ms): candidates = [ create_song('很爱很爱你', '刘若英', '脱掉高跟鞋世界巡回演唱会', '05:24') ] - assert score_fn(song, candidates[0]) >= MIN_SCORE + assert score_fn(song, candidates[0]) >= STANDBY_DEFAULT_MIN_SCORE