Skip to content

feat: improved radio/fm algorithm with user feedback #1053

Description

@LWWZH

简介与背景

当前 FM/Radio 模式的歌曲推荐算法较为基础。Radio.fetch_songs_funcfeeluown/player/radio.py:18)存在两个已知问题:

  1. 排序粗糙:获取到的相似歌曲没有按相似度排序(代码中有 TODO: "sort the songs by similarity")
  2. 无反馈机制:用户跳过/删除歌曲的行为没有被用来调整后续推荐

现有代码中的策略描述:

  1. similar songs (most providers have this functionality)
  2. a random song in the same album or similar album
  3. popular songs sang by the same artist or similar artist

此外,FM._on_songs_fetchedfeeluown/player/fm.py:110)在获取不到足够歌曲时直接退出 FM 模式,体验不够友好。

同时,项目已具备 AI 基础设施(feeluown/ai/copilot.py,LangChain/OpenAI 兼容),可以作为增强推荐的底层能力。

方案概述

Phase 1: 基础改进(无 AI)

1.1 用户反馈信号

Playlist 中新增用户行为事件:

# feeluown/player/playlist.py
self.song_skipped = Signal()    # (song, duration_played)
self.song_liked = Signal()      # (song,)
self.song_disliked = Signal()   # (song,)

触发时机:

  • skip: 用户在播放 < 30% 时点击 next
  • like: 用户点击收藏按钮
  • dislike: 用户从播放列表中删除当前歌曲(已有 remove 逻辑)

1.2 反馈存储

新增 FeedbackStore(建议 feeluown/player/feedback.py):

class FeedbackStore:
    """基于本地 SQLite 的轻量反馈存储"""

    def record_skip(self, song, duration_played)
    def record_like(self, song)
    def record_dislike(self, song)
    def get_song_score(self, song) -> float  # -1.0 ~ 1.0
    def get_similar_song_weight(self, song, candidate) -> float

存储位置:~/.feeluown/feedback.db(SQLite),表结构:

CREATE TABLE feedback (
    song_uri TEXT PRIMARY KEY,
    skip_count INTEGER DEFAULT 0,
    like_count INTEGER DEFAULT 0,
    dislike_count INTEGER DEFAULT 0,
    total_play_duration REAL DEFAULT 0,
    last_played_at TIMESTAMP
);

CREATE TABLE song_similarity (
    song_uri TEXT,
    candidate_uri TEXT,
    score REAL,
    PRIMARY KEY (song_uri, candidate_uri)
);

1.3 改进 Radio 排序

修改 Radio.fetch_songs_funcfeeluown/player/radio.py:18):

def fetch_songs_func(self, number):
    valid_songs = []
    while len(valid_songs) < number:
        if not self._stack:
            break
        song = self._stack.popleft()
        if song not in self._app.playlist.list():
            continue
        provider = self._app.library.get(song.source)
        if not isinstance(provider, SupportsSongSimilar):
            continue
        songs = provider.song_list_similar(song)
        for candidate in songs:
            if candidate not in self._songs_set:
                valid_songs.append(candidate)
                self._similarity_cache[(song, candidate)] = 1.0

    # 新增:按反馈分数排序
    scored = []
    for song in valid_songs:
        score = self._feedback_store.get_song_score(song)
        scored.append((score, song))
    scored.sort(key=lambda x: x[0], reverse=True)

    for _, song in scored[:number]:
        self._stack.append(song)
        self._songs_set.add(song)
    return [s for _, s in scored[:number]]

1.4 FM 模式降级而非退出

修改 FM._on_songs_fetchedfeeluown/player/fm.py:110):

def _on_songs_fetched(self, future):
    try:
        songs = future.result()
    except asyncio.CancelledError:
        logger.exception("fm-fetch-songs task is cancelled")
    except ProviderIOError:
        logger.exception("fm-fetch-songs io error")
    else:
        if len(songs) < self._minimum_per_fetch:
            # 改进:不立即退出,而是从播放历史中随机补充
            fallback_songs = self._get_fallback_songs(self._minimum_per_fetch)
            if fallback_songs:
                self._feed_playlist(fallback_songs)
            else:
                self._app.show_msg(t("track-radio-not-enough"))
                self.deactivate()
        else:
            self._feed_playlist(songs)
    finally:
        self._is_fetching_songs = False

Phase 2: AI 增强(可选,后续迭代)

利用 feeluown/ai/copilot.py 的 LangChain 集成,基于用户反馈历史生成更精准的推荐:

  • 分析用户喜欢的歌曲的 artist/genre/era 模式
  • 使用 embedding 模型计算歌曲语义相似度(而非仅依赖 provider 的 similar 接口)
  • 在用户跳过特定歌曲时,反向学习排除该特征

此阶段依赖 Phase 1 的反馈数据积累,建议作为后续独立 PR。

不在本次范围内

  • 跨 provider 的相似度整合(Phase 1 仅使用当前 provider 的 similar 接口)
  • 社交功能(共享 radio 偏好)
  • 实时学习模型(使用本地统计而非在线学习)

参考

  • feeluown/player/radio.py:18 — Radio.fetch_songs_func(含 TODO)
  • feeluown/player/radio.py:49 — TODO: "sort the songs by similarity"
  • feeluown/player/fm.py:83_on_playlist_eof_reached
  • feeluown/player/fm.py:110_on_songs_fetched(降级退出逻辑)
  • feeluown/ai/copilot.py — AI 基础设施
  • feeluown/player/recently_played.py — 播放历史数据

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions