Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat](*): AI based radio #899

Merged
merged 5 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
23 changes: 22 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,24 @@ 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
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):
Expand All @@ -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)

Expand Down Expand Up @@ -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
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
46 changes: 2 additions & 44 deletions feeluown/library/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@
SupportsSongLyric, SupportsSongMV, SupportsSongMultiQuality,
SupportsVideoMultiQuality, SupportsSongWebUrl, SupportsVideoWebUrl,
)
from .similarity import get_standby_origin_similarity, FULL_SCORE

if TYPE_CHECKING:
from .ytdl import Ytdl


logger = logging.getLogger(__name__)

FULL_SCORE = 10
MIN_SCORE = 5
T_p = TypeVar('T_p')

Expand All @@ -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."""

Expand Down Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions feeluown/library/similarity.py
Original file line number Diff line number Diff line change
@@ -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
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
Loading
Loading