Skip to content

Commit

Permalink
[feat](*): AI based ratio
Browse files Browse the repository at this point in the history
  • Loading branch information
cosven committed Jan 27, 2025
1 parent d9f39dc commit 95afb59
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 16 deletions.
22 changes: 22 additions & 0 deletions feeluown/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 17 additions & 1 deletion feeluown/gui/uimain/playlist_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -65,13 +66,19 @@ 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.
# type ignore: q_app has focusChanged signal, but type checker can't find it.
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):
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion feeluown/gui/widgets/settings.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
21 changes: 16 additions & 5 deletions feeluown/library/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
64 changes: 64 additions & 0 deletions feeluown/library/text2song.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions feeluown/player/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,8 @@
'PlaylistMode',
'SongRadio',
'SongsRadio',
'AIRadio',
'AI_RADIO_SUPPORTED',

'Player',
'Playlist',
Expand Down
83 changes: 83 additions & 0 deletions feeluown/player/ai_radio.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 3 additions & 9 deletions feeluown/player/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -346,18 +346,18 @@ 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"""
with self._songs_lock:
if self.current_song is not None:
self.set_current_song_none()
length = len(self._songs)
self._songs.clear()
if length > 0:
self.songs_removed.emit(0, length)
self._songs.clear()
self._bad_songs.clear()

def list(self):
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 95afb59

Please sign in to comment.