Skip to content

Commit 00e67b1

Browse files
authored
[feat](*): AI based radio (#899)
AI 基于当前歌单,自动推荐歌曲来延长列表 ~
1 parent d9f39dc commit 00e67b1

File tree

12 files changed

+379
-105
lines changed

12 files changed

+379
-105
lines changed

feeluown/app/config.py

+22
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,26 @@ def create_config() -> Config:
7272
config.deffield(
7373
'PLAYBACK_CROSSFADE_DURATION', type_=int, default=500, desc='淡入淡出持续时间'
7474
)
75+
config.deffield(
76+
'OPENAI_API_BASEURL',
77+
type_=str,
78+
default='',
79+
desc='OpenAI API base url'
80+
)
81+
config.deffield('OPENAI_API_KEY', type_=str, default='', desc='OpenAI API key')
82+
config.deffield('OPENAI_MODEL', type_=str, default='', desc='OpenAI model name')
83+
config.deffield(
84+
'AI_RADIO_PROMPT',
85+
type_=str,
86+
default='''\
87+
你是一个音乐推荐系统。你根据用户的歌曲列表分析用户的喜好,给用户推荐一些歌。默认推荐5首歌。
88+
89+
有几个注意点
90+
1. 不要推荐与用户播放列表中一模一样的歌曲。不要推荐用户不喜欢的歌曲。不要重复推荐。
91+
2. 你返回的内容只应该有 JSON,其它信息都不需要。也不要用 markdown 格式返回。
92+
3. 你推荐的歌曲需要使用类似这样的 JSON 格式
93+
[{"title": "xxx", "artists_name": "yyy", "description": "推荐理由"}]
94+
''',
95+
desc='AI 电台功能的提示词'
96+
)
7597
return config

feeluown/gui/uimain/playlist_overlay.py

+22-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
QColor, QLinearGradient, QPalette, QPainter,
88
)
99

10-
from feeluown.player import PlaybackMode, SongsRadio
10+
from feeluown.player import PlaybackMode, SongsRadio, AIRadio, AI_RADIO_SUPPORTED
1111
from feeluown.gui.helpers import fetch_cover_wrapper, esc_hide_widget
1212
from feeluown.gui.components.player_playlist import PlayerPlaylistView
1313
from feeluown.gui.widgets.textbtn import TextButton
@@ -46,6 +46,7 @@ def __init__(self, app, *args, **kwargs):
4646
self._playback_mode_switch = PlaybackModeSwitch(app)
4747
self._goto_current_song_btn = TextButton('跳转到当前歌曲')
4848
self._songs_radio_btn = TextButton('自动续歌')
49+
self._ai_radio_btn = TextButton('AI电台')
4950
# Please update the list when you add new buttons.
5051
self._btns = [
5152
self._clear_playlist_btn,
@@ -65,13 +66,24 @@ def __init__(self, app, *args, **kwargs):
6566
self._clear_playlist_btn.clicked.connect(self._app.playlist.clear)
6667
self._goto_current_song_btn.clicked.connect(self.goto_current_song)
6768
self._songs_radio_btn.clicked.connect(self.enter_songs_radio)
69+
self._ai_radio_btn.clicked.connect(self.enter_ai_radio)
6870
esc_hide_widget(self)
6971
q_app = QApplication.instance()
7072
assert q_app is not None # make type checker happy.
7173
# type ignore: q_app has focusChanged signal, but type checker can't find it.
7274
q_app.focusChanged.connect(self.on_focus_changed) # type: ignore
7375
self._app.installEventFilter(self)
7476
self._tabbar.currentChanged.connect(self.show_tab)
77+
78+
if (
79+
AI_RADIO_SUPPORTED is True
80+
and self._app.config.OPENAI_API_KEY
81+
and self._app.config.OPENAI_MODEL
82+
and self._app.config.OPENAI_API_BASEURL
83+
):
84+
self._ai_radio_btn.clicked.connect(self.enter_ai_radio)
85+
else:
86+
self._ai_radio_btn.setDisabled(True)
7587
self.setup_ui()
7688

7789
def setup_ui(self):
@@ -97,6 +109,7 @@ def setup_ui(self):
97109
self._btn_layout.addWidget(self._playback_mode_switch)
98110
self._btn_layout.addWidget(self._goto_current_song_btn)
99111
self._btn_layout2.addWidget(self._songs_radio_btn)
112+
self._btn_layout2.addWidget(self._ai_radio_btn)
100113
self._btn_layout.addStretch(0)
101114
self._btn_layout2.addStretch(0)
102115

@@ -125,6 +138,14 @@ def enter_songs_radio(self):
125138
self._app.fm.activate(radio.fetch_songs_func, reset=False)
126139
self._app.show_msg('“自动续歌”功能已激活')
127140

141+
def enter_ai_radio(self):
142+
if self._app.playlist.list():
143+
radio = AIRadio(self._app)
144+
self._app.fm.activate(radio.fetch_songs_func, reset=False)
145+
self._app.show_msg('已经进入 AI 电台模式 ~')
146+
else:
147+
self._app.show_msg('播放列表为空,暂时不能开启 AI 电台')
148+
128149
def show_tab(self, index):
129150
if not self.isVisible():
130151
return

feeluown/gui/widgets/settings.py

+23-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from PyQt5.QtCore import pyqtSignal
22
from PyQt5.QtWidgets import QDialog, QWidget, QCheckBox, \
3-
QVBoxLayout, QHBoxLayout
3+
QVBoxLayout, QHBoxLayout, QPlainTextEdit, QPushButton
44

55
from feeluown.gui.widgets.magicbox import KeySourceIn
66
from feeluown.gui.widgets.header import MidHeader
@@ -65,6 +65,26 @@ def __init__(self, app, *args, **kwargs):
6565
self._layout.addStretch(0)
6666

6767

68+
class AISettings(QWidget):
69+
def __init__(self, app, *args, **kwargs):
70+
super().__init__(*args, **kwargs)
71+
72+
self._app = app
73+
self._prompt_editor = QPlainTextEdit(self)
74+
self._save_btn = QPushButton('保存', self)
75+
76+
self._layout = QHBoxLayout(self)
77+
self._layout.addWidget(self._prompt_editor)
78+
self._layout.addWidget(self._save_btn)
79+
self._prompt_editor.setPlainText(self._app.config.AI_RADIO_PROMPT)
80+
self._prompt_editor.setMaximumHeight(200)
81+
82+
self._save_btn.clicked.connect(self.save_prompt)
83+
84+
def save_prompt(self):
85+
self._app.config.AI_RADIO_PROMPT = self._prompt_editor.toPlainText()
86+
87+
6888
class SettingsDialog(QDialog):
6989
def __init__(self, app, parent=None):
7090
super().__init__(parent=parent)
@@ -86,6 +106,8 @@ def render(self):
86106
self._layout = QVBoxLayout(self)
87107
self._layout.addWidget(MidHeader('搜索来源'))
88108
self._layout.addWidget(toolbar)
109+
self._layout.addWidget(MidHeader('AI 电台(PROMPT)'))
110+
self._layout.addWidget(AISettings(self._app))
89111
self._layout.addWidget(MidHeader('播放器'))
90112
self._layout.addWidget(PlayerSettings(self._app))
91113
self._layout.addStretch(0)

feeluown/library/library.py

+2-44
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@
2222
SupportsSongLyric, SupportsSongMV, SupportsSongMultiQuality,
2323
SupportsVideoMultiQuality, SupportsSongWebUrl, SupportsVideoWebUrl,
2424
)
25+
from .similarity import get_standby_origin_similarity, FULL_SCORE
2526

2627
if TYPE_CHECKING:
2728
from .ytdl import Ytdl
2829

2930

3031
logger = logging.getLogger(__name__)
3132

32-
FULL_SCORE = 10
3333
MIN_SCORE = 5
3434
T_p = TypeVar('T_p')
3535

@@ -38,48 +38,6 @@ def raise_(e):
3838
raise e
3939

4040

41-
def default_score_fn(origin, standby):
42-
43-
# TODO: move this function to utils module
44-
def duration_ms_to_duration(ms):
45-
if not ms: # ms is empty
46-
return 0
47-
parts = ms.split(':')
48-
assert len(parts) in (2, 3), f'invalid duration format: {ms}'
49-
if len(parts) == 3:
50-
h, m, s = parts
51-
else:
52-
m, s = parts
53-
h = 0
54-
return int(h) * 3600 + int(m) * 60 + int(s)
55-
56-
score = FULL_SCORE
57-
if origin.artists_name != standby.artists_name:
58-
score -= 3
59-
if origin.title != standby.title:
60-
score -= 2
61-
if origin.album_name != standby.album_name:
62-
score -= 2
63-
64-
if isinstance(origin, SongModel):
65-
origin_duration = origin.duration
66-
else:
67-
origin_duration = duration_ms_to_duration(origin.duration_ms)
68-
if isinstance(standby, SongModel):
69-
standby_duration = standby.duration
70-
else:
71-
standby_duration = duration_ms_to_duration(standby.duration_ms)
72-
if abs(origin_duration - standby_duration) / max(origin_duration, 1) > 0.1:
73-
score -= 3
74-
75-
# Debug code for score function
76-
# print(f"{score}\t('{standby.title}', "
77-
# f"'{standby.artists_name}', "
78-
# f"'{standby.album_name}', "
79-
# f"'{standby.duration_ms}')")
80-
return score
81-
82-
8341
class Library:
8442
"""Resource entrypoints."""
8543

@@ -214,7 +172,7 @@ async def prepare_media(standby, policy):
214172
else:
215173
pvd_ids = [pvd.identifier for pvd in self._filter(identifier_in=source_in)]
216174
if score_fn is None:
217-
score_fn = default_score_fn
175+
score_fn = get_standby_origin_similarity
218176
limit = max(limit, 1)
219177

220178
q = '{} {}'.format(song.title_display, song.artists_name_display)

feeluown/library/similarity.py

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from .models import SongModel
2+
3+
FULL_SCORE = 10
4+
5+
6+
def get_standby_origin_similarity(origin, standby):
7+
8+
# TODO: move this function to utils module
9+
def duration_ms_to_duration(ms):
10+
if not ms: # ms is empty
11+
return 0
12+
parts = ms.split(':')
13+
assert len(parts) in (2, 3), f'invalid duration format: {ms}'
14+
if len(parts) == 3:
15+
h, m, s = parts
16+
else:
17+
m, s = parts
18+
h = 0
19+
return int(h) * 3600 + int(m) * 60 + int(s)
20+
21+
score = FULL_SCORE
22+
unsure_score = 0
23+
if origin.artists_name != standby.artists_name:
24+
score -= 3
25+
if origin.title != standby.title:
26+
score -= 2
27+
# Only compare album_name when it is not empty.
28+
if origin.album_name:
29+
if origin.album_name != standby.album_name:
30+
score -= 2
31+
else:
32+
score -= 1
33+
unsure_score += 2
34+
35+
if isinstance(origin, SongModel):
36+
origin_duration = origin.duration
37+
else:
38+
origin_duration = duration_ms_to_duration(origin.duration_ms)
39+
if isinstance(standby, SongModel):
40+
standby_duration = standby.duration
41+
else:
42+
standby_duration = duration_ms_to_duration(standby.duration_ms)
43+
# Only compare duration when it is not empty.
44+
if origin_duration:
45+
if abs(origin_duration - standby_duration) / max(origin_duration, 1) > 0.1:
46+
score -= 3
47+
else:
48+
score -= 1
49+
unsure_score += 3
50+
51+
# Debug code for score function
52+
# print(f"{score}\t('{standby.title}', "
53+
# f"'{standby.artists_name}', "
54+
# f"'{standby.album_name}', "
55+
# f"'{standby.duration_ms}')")
56+
return ((score - unsure_score) / (FULL_SCORE - unsure_score)) * FULL_SCORE

feeluown/library/text2song.py

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import uuid
2+
import json
3+
4+
from .models import BriefSongModel, ModelState
5+
6+
7+
class AnalyzeError(Exception):
8+
pass
9+
10+
11+
def analyze_text(text):
12+
def json_fn(each):
13+
try:
14+
return each['title'], each['artists_name']
15+
except KeyError:
16+
return None
17+
18+
def line_fn(line):
19+
parts = line.split('|')
20+
if len(parts) == 2 and parts[0]: # title should not be empty
21+
return (parts[0], parts[1])
22+
return None
23+
24+
try:
25+
data = json.loads(text)
26+
except json.JSONDecodeError:
27+
lines = text.strip().split('\n')
28+
if lines:
29+
first_line = lines[0].strip()
30+
if first_line in ('---', '==='):
31+
parse_each_fn = line_fn
32+
items = [each.strip() for each in lines[1:] if each.strip()]
33+
elif first_line == '```json':
34+
try:
35+
items = json.loads(text[7:-3])
36+
except json.JSONDecodeError:
37+
raise AnalyzeError('invalid JSON content inside code block')
38+
parse_each_fn = json_fn
39+
else:
40+
raise AnalyzeError('invalid JSON content')
41+
else:
42+
if not isinstance(data, list):
43+
# should be like [{"title": "xxx", "artists_name": "yyy"}]
44+
raise AnalyzeError('content has invalid format')
45+
parse_each_fn = json_fn
46+
items = data
47+
48+
err_count = 0
49+
songs = []
50+
for each in items:
51+
result = parse_each_fn(each)
52+
if result is not None:
53+
title, artists_name = result
54+
song = BriefSongModel(
55+
source='dummy',
56+
identifier=str(uuid.uuid4()),
57+
title=title,
58+
artists_name=artists_name,
59+
state=ModelState.not_exists,
60+
)
61+
songs.append(song)
62+
else:
63+
err_count += 1
64+
return songs, err_count

feeluown/player/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .metadata_assembler import MetadataAssembler
88
from .fm import FM
99
from .radio import SongRadio, SongsRadio
10+
from .ai_radio import AIRadio, AI_RADIO_SUPPORTED
1011
from .lyric import LiveLyric, parse_lyric_text, Line as LyricLine, Lyric
1112
from .recently_played import RecentlyPlayed
1213
from .delegate import PlayerPositionDelegate
@@ -23,6 +24,8 @@
2324
'PlaylistMode',
2425
'SongRadio',
2526
'SongsRadio',
27+
'AIRadio',
28+
'AI_RADIO_SUPPORTED',
2629

2730
'Player',
2831
'Playlist',

0 commit comments

Comments
 (0)