FeelUOwn - feel your own

FeelUOwn 是一个用户友好、可玩性强的播放器

https://user-images.githubusercontent.com/4962134/43506587-1c56c960-959d-11e8-964f-016159cbeeb9.png

它主要有以下特性:

  • 安装简单,使用方便,新手友好
  • 默认提供国内各音乐平台插件(网易云、虾米、QQ)
  • 与 tmux,Emacs,Vim 等工具可以方便集成
  • 核心模块有较好文档和测试覆盖

快速上手

FeelUOwn 使用 Python 3 进行开发,目前默认使用 mpv 作为其播放引擎, 基于 PyQt5 构建 GUI。

安装

Ubuntu

下面命令在 Ubuntu 18.04 中测试通过,理论上其它 Linux 发行版 安装流程类似,只是一些包的名字可能有些许差别。

# 安装 Python 3 和 pip3(大部分系统已经安装好了)
sudo apt-get install python3 python3-pip

# 安装 libmpv1
sudo apt-get install libmpv1

# 安装 PyQt5
sudo apt-get install python3-pyqt5
sudo apt-get install python3-pyqt5.qtopengl
sudo apt-get install python3-pyqt5.qtsvg

# 安装 dbus-python
sudo apt-get install python3-dbus
sudo apt-get install python3-dbus.mainloop.pyqt5

# 安装 feeluown (是一个 Python 包)
# --upgrade 代表安装最新版,--user 代表不安装到系统目录
pip3 install 'feeluown>=3.0[battery]' --upgrade --user
pip3 install pyopengl

# 运行 feeluown -h 来测试安装是否成功
# 如果提示 Commmand Not Found,请查看文档「常见问题」部分
feeluown -h

# 生成桌面图标
feeluown-genicon

# (可能还需要安装)使用 fcitx 输入法的用户可能需要安装
# 否则有可能不能在 GUI 中切换输入法
sudo apt-get install fcitx-frontend-qt5

macOS

# macOS Monterey(版本12)实测可以安装,版本 11 可能不能正常安装
# (https://github.com/feeluown/FeelUOwn/issues/421)
brew tap feeluown/feeluown
brew install feeluown --with-battery # 更多选项见 `brew info feeluown`
feeluown genicon  # 在桌面会生成一个 FeelUOwn 图标

Windows

你可以从 发布页 直接下载打包好的压缩包。 也可以按照如下步骤手动进行安装:

  1. 安装 Python 3,参考 链接 <https://www.python.org/downloads/windows/> (请勿从应用商店安装)
  2. 下载 mpv-1.dll , 将 mpv-1.dll 放入 C:\Windows\System32 目录。
  3. 安装 PyQt5,在 cmd 中运行 pip3 install PyQt5 -i https://pypi.douban.com/simple
  4. 安装 feeluown,在 cmd 中运行 pip3 install feeluown[battery,win32]
  5. 在 cmd 中运行 python -m feeluown genicon 命令,可以生成桌面图标

基本使用

大家有几种方式启动 FeelUOwn:

  1. 直接双击桌面 FeelUOwn 图标,这时启动 GUI/Daemon 混合模式
  2. 在命令行中运行 feeluown 命令,这时也是混合模式
  3. 在命令行中运行 feeluown -nw 命令,这时是 Daemon 模式

Daemon 模式的使用方法,这里简单说明: (提示:如果不熟悉命令行,DAEMON 模式可能会有一定的折腾)

feeluown -nw  # 使用 Daemon 模式启动 feeluown
fuo status  # 查看播放器状态
fuo search 周杰伦  # 搜索歌曲
fuo play fuo://netease/songs/470302665  # 播放:(世界が终るまでは…)《灌篮高手》

如果大家对 NetCat 工具熟悉

nc localhost 23333
# 输入 `status` 命令,可以查看播放器状态
# 输入 `fuo play fuo://netease/songs/470302665` 可以播放音乐

关于 Daemon 更多使用细节,大家可以参考运行 fuo -h 来查看帮助文档

特性

安装相对简单,新手友好

参考 快速上手 文档进行安装。

提供国内各音乐平台插件

基于文本的歌单

将下面内容拷贝到文件 ~/.FeelUOwn/collections/favorite.fuo 中,重启 FeelUOwn 就可以看到此歌单:

fuo://netease/songs/16841667  # No Matter What - Boyzone
fuo://netease/songs/65800     # 最佳损友 - 陈奕迅
fuo://xiami/songs/3406085     # Love Story - Taylor Swift
fuo://netease/songs/5054926   # When You Say Noth… - Ronan Keating
fuo://qqmusic/songs/97773     # 晴天 - 周杰伦
fuo://qqmusic/songs/102422162 # 给我一首歌的时间 … - 周杰伦,蔡依林
fuo://xiami/songs/1769834090  # Flower Dance - DJ OKAWARI

你可以通过 gist 来分享自己的歌单,也可以通过 Dropbox 或 git 来在不同设备上同步这些歌单。

支持读取 fuorc 文件

你配置过 .emacs 或者 .vimrc 吗? .fuorc 和它们一样强大! 参考 配置文件 文档来编写自己的 rc 文件吧~

提供基于 TCP 的控制协议

比如:

查看播放器状态 echo status | nc localhost 23333
暂停播放      echo status | nc localhost 23333
搜索歌曲      echo "search 周杰伦" | nc localhost 23333

因此,它 可以方便的与 Tmux, Emacs, Slack 等常用程序和软件集成

支持无 GUI 模式启动

配置文件

类似 Vim 的 .vimrc 、Emacs 的 init.el ,feeluown 也有自己的配置文件 .fuorc

fuorc 文件是一个 Python 脚本,它位于 ~/.fuorc 目录下,我们可以在其中使用任意 Python 语法。 feeluown 启动时,第一步就是加载并解析该配置文件。通常,我们可以在配置文件中作以下事情:

  1. 配置部分选项
  2. 定制一些小功能

一个 fuorc 文件示例:

import os


# 自定义配置
config.THEME = 'dark'
config.COLLECTIONS_DIR = '~/Dropbox/public/music'
config.AUDIO_SELECT_POLICY = '>>>'


# 一个小功能:切换歌曲时,发送系统通知
def notify_song_changed(song):
    if song is not None:
        title = song.title_display
        artists_name = song.artists_name_display
        song_str = f'{title}-{artists_name}'
        os.system(f'notify-send "{song_str}"')

when('app.playlist.song_changed', notify_song_changed)

# 让编辑器识别这是一个 Python 文件
#
# Local Variables:
# mode: python
# End:
#
# vim: ft=python

原理简述

feeluown 使用 Python 的 exec 方法来加载(执行)配置文件。执行时, 会暴露 config 对象和部分函数到这个作用域中。

函数

目前暴露到该作用域的函数有

feeluown.fuoexec.add_hook(signal_symbol: str, func: Callable, use_symbol: bool = False, **kwargs)[source]

add hook on signal

Parameters:
  • signal_symbol – app.{object}.{signal_name} .
  • func – Signal receiver.
  • use_symbol – Whether to connect the signal to the symbol of the receiver. If this is true, the real receiver is lazy found by the symbol, and the signal connects to a symbol instead of a function object. If this is false, problem may occur when the rcfile is reloaded. because there the signal connects to two same receivers.
  • kwargs – This is directly passed to Signal.connect.
>>> def func(): pass
>>> add_hook('app.initialized', func)

New in version 3.8: The kwargs keyword argument.

feeluown.fuoexec.rm_hook(signal_symbol: str, slot: Callable, use_symbol: bool = False)[source]

Remove slot from signal.

If slot_symbol is not connected, this does nothing.

feeluown.fuoexec.source(filepath: str)[source]

Exec a py file.

config 对象

configfeeluown.config.Config 的实例,常见使用场景有两种:

>>> theme = config.THEME  # 获取配置项的值
>>> config.THEME = 'dark'  # 设置配置项的值

目前支持的配置项如下

通用配置项

名称 类型 默认值 描述
DEBUG bool False 是否为调试模式
MODE str 0x0000 CLI or GUI 模式
THEME str auto auto/light/dark
COLLECTIONS_DIR str '' 本地收藏所在目录
LOG_TO_FILE bool True 将日志输出到文件中
AUDIO_SELECT_POLICY str hq<> feeluown.media.Quality.SortPolicy
VIDEO_SELECT_POLICY str hd<> feeluown.media.Quality.SortPolicy

MPV 播放器配置项 (使用 MPV 做为播放引擎时生效)

名称 类型 默认值 描述
MPV_AUDIO_DEVICE str auto MPV 播放设备
class feeluown.config.Config[source]

配置模块

用户可以在 rc 文件中配置各个选项的值

Roadmap

FeelUOwn 项目发展方向

  1. 一个好用的、能 hack 的播放器
  2. 一个优秀的 Python 项目

2020 下半年

帮助用户发现音乐以及背后的故事

  1. 支持查找类似歌曲?
  2. 集成豆瓣、百科、网易云评论等平台资源?
  3. 支持集成 bilibili/youtube 等视频资源?

已有功能优化

  1. 系统托盘?
  2. fuo 文件功能丰富?
  3. 更好地找候选歌曲?

代码结构优化

  1. 去除 feeluown 概念,按照 daemon 和 gui 两种模式来组织代码
  2. 调研 type hint 的必要性与可行性?

fuo 协议优化

常见问题

使用相关问题

安装完成后,运行 feeluown 提示 Command ‘feeluown’ not found

一般来说,安装之后, feeluown 命令会在 ~/.local/bin/ 目录下。

可以通过下面命令查看目录下是否有 feeluown:

ls -l ~/.local/bin/feeluown

如果输出正常则说明安装已经成功了, 大家可以修改 PATH 环境变量即可。

如果是使用 bash 或者 zsh,大家可以在 ~/.bashrc 或者 ~/.zshrc 文件中加入一行:

export PATH=~/.local/bin:$PATH

然后重新进入 shell,下次就可以直接运行 feeluown 了。

开发相关问题

本地开发快速上手

首先,非常感谢您愿意贡献自己的力量让 FeelUOwn 播放器变得更好~

推荐的开发流程

这里假设读者已经对 Git 和 Python 相关工具比较熟悉

  1. 在 GitHub 上 fork 一份代码到自己名下

  2. clone 自己 fork 的代码: git clone git@github.com:username/FeelUOwn.git

  3. 在 Home 目录或者项目目录下创建一个虚拟环境(假设你已经安装 Python 3)

    # 以在 Home 目录下创建虚拟环境为例
    
    # 创建 ~/.venvs 目录,如果之前没有创建的话
    mkdir -p ~/.venvs
    
    # 创建虚拟环境(大家也可以选择用 virtualenv)
    python3 -m venv ~/.venvs/fuo
    
    # 激活虚拟环境
    source ~/.venvs/fuo/bin/activate
    
    # 安装项目依赖
    pip3 install -e .
    

Note

在 Linux 或者 macOS 下,大家一般都是用 apt-get 或者 brew 将 PyQt5 安装到 Python 系统包目录, 也就是说,在虚拟环境里面, 不能 import 到 PyQt5 这个包 。建议的解决方法是:

  1. 创建一个干净的虚拟环境(不包含系统包)
  2. pip3 install -e . 安装项目以及依赖
  3. 将虚拟环境配置改成包含系统包,将 ~/.venvs/fuo/pyvenv.cfg 配置文件中的 include-system-site-packages 字段的值改为 true

这样可以尽量避免包版本冲突、以及依赖版本过低的情况

  1. 以 debug 模式启动 feeluown

    # 运行 feeluown -h 查看更多选项
    feeluown --debug
    
  2. 修改代码

  3. push 到自己的 master 或其它分支

  4. 给 FeelUOwn 项目提交 PR

  5. (可选)在开发者群中通知大家进行 review

程序启动流程

feeluown(fuo) 命令入口文件为 feeluown/entry_points/run.py ,主函数为 run 函数。

程序架构

FeelUOwn 中有一个核心对象 app, 它是我们进行几乎一切操作的入口。 在初始化 app 时,我们会实例化一些类,比如 Library/LiveLyric, 这些实例工作不依赖 app 对象,我们把它们放在 feeluown 包中。另外,我们也会创建很多 Manager 实例, 比如 PluginManager/HotkeyManager 等,它们往往都是依赖 app 的, 我们目前将它们各自作为一个模块放在 feeluown 包中。

主要模块

稳定 名字 模块
🔴 音乐资源模型 feeluown.models
🔴 音乐库 feeluown.library.Library
🔴 播放器 feeluown.player
🔴 fuo 协议 feeluown.protocol.FuoProcotol
🔴 版本 feeluown.version.VersionManager
🔴 小提示管理 feeluown.tips.TipsManager
🔴 本地收藏管理 feeluown.collection.CollectionManager
🔴 浏览历史记录 feeluown.browser
🔴 快捷键管理 feeluown.hotkey.HotkeyManager
🔴 图片管理 feeluown.image
🔴 资源提供方 UI feeluown.gui.uimodels.ProviderUiManager
🔴 我的音乐 UI feeluown.gui.uimodels.MyMusicUiManager
🔴 歌单列表 UI feeluown.gui.uimodels.playlist

界面

FeelUOwn 启动时有两种模式可以选择,CLI 模式和 GUI 模式,大部分 Manager 可以在两种模式下工作,也有一部分 Manager只在 GUI 模式下工作,这些 Manager 往往和 UI 操作相关。

FeelUOwn UI 部分的核心对象是 app.ui, 我们下面就从界面开始, 来详细了解 FeelUOwn 程序的整体架构。

整个 GUI 区域划分比较简单和规整,下图大致的描述了 GUI 的几个主要组成部分。

图中文字部分对应的都是代码中的变量,它们也比较好的反映了对应区域的功能。 一开始对项目可能不是特别熟悉,大家可以对照这个图来看代码。

https://user-images.githubusercontent.com/4962134/43657563-cf19c1aa-9788-11e8-9114-e83b9c9e41cf.png

从区域划分来看,程序主界面主要分为四大块(蓝色部分):

  1. magicbox : 用户搜索、显示用户操作通知、执行 fuo 命令、 执行 Python 代码相关操作都在此组件中完成
  2. left_panel : 显示音乐库、用户操作历史记录、用户歌单列表
  3. right_panel : 目前显示歌单列表详情、歌手详情等。 之后可能会支持更多其实形式的展示:比如批量展示专辑。
  4. pc_panel : 与播放器相关的控制部分,主要是播放/暂停、进度条、 音量调节、显示当前播放列表、修改播放模式等操作按钮。

各大块可以拆分成小块(红色部分):

  • left_panel 区域
    • provider_view 组件展示应用支持的音乐提供方
    • histories_view 组件展示用户浏览记录
    • playlists_view 组件展示用户歌单列表
  • right_panel 区域
    • songs_table 批量展示歌曲,比如:歌单中的歌曲、搜索结果的歌曲部分等,
    • table_overview 是对 songs_table 的概览,由封面图和描述组成。

接口参考手册

这个参考手册描述了 feeluown 的几大组成部分,各组成部分的主要模块, 以及模块的相对稳定接口。

播放模块

播放模块由两部分组成:播放列表(Playlist)和播放器(Player)。

播放列表维护了一个媒体资源集合,并提供若干资源获取接口,播放器通过接口获取资源, 然后进行播放。这些接口包括: current_song, next_song, previous_song

播放列表吐资源给播放器的时候,是有一定的顺序的,我们称之为回放模式(PlaybackMode)。 回放模式有四种:单曲循环;顺序;循环;随机。上述三个资源访问接口吐资源的顺序都会受回放模式影响。

当播放列表 current_song 变化时,它会发出 song_changed 信号,播放器会监听该信号, 从而播放、停止(song 为空时)或者切换歌曲。播放歌曲时,播放器的状态(State)会发生变化, 当没有歌曲播放的时候,播放器为停止(stopped)状态,有歌曲播放的时候,为正在播放状态。 当一首歌开始播放后,播放器会发出 media_changed 信号, 当一首歌曲播放完毕时,播放器会发出 song_finished 信号。

播放列表和播放器之间的调用关系如下:

Playlist                                         (Mpv)Player

    current_song.setter ---[song_changed]---> play_song
       ^                                      |
       |                                      | <song==None>
       |<- play_song                          |--------------> stop
       |<- play_next/play_previous            |
       |<- play_next <-|                  prepare_media
       |<- replay    <-| <mode==one_loop>     |
                       |                      v
                       |                    play ---[media_changed]--->
                 [song_finished]
                       |
                       |
                       ---- event(END_FILE)
                                   ^
                                   |
                                MpvPlayer

通用管理模块

feeluown.task.is_in_loop_thread()[source]

check if current thread has event loop

class feeluown.task.TaskKind[source]

An enumeration.

preemptive = 'preemptive'

preemptive task

cooperative = 'cooperative'

cooperative task

class feeluown.task.PreemptiveTaskSpec(mgr, name)[source]

Preemptive task specification (threadsafe)

bind_coro(coro)[source]

run the coroutine and bind the task

it will cancel the previous task if exists

Returns:asyncio.Task
bind_blocking_io(func, *args)[source]

run blocking io func in a thread executor, and bind the task

it will cancel the previous task if exists

Returns:asyncio.Task
class feeluown.task.TaskManager(app)[source]

named task manager

Usage:

async def fetch_song():
     pass

task_name = 'unique-name'

task_spec = task_mgr.get_or_create(task_name, TaskType.preemptive)
task = task_spec.bind_coro(fetch_song())
get_or_create(name, kind=<TaskKind.preemptive: 'preemptive'>)[source]

get task spec, it will be created if not exists

Parameters:
  • name – task identifier(name)
  • kindTaskKind

TODO: client should register first, then get by name

GUI 相关管理模块

Note

目前,大部分 GUI 组件的接口都不稳定,我们在实现插件时,如果需要从操作 GUI 组件, 请调用以下模块的接口。如果我们想实现的功能通过以下接口在暂时实现不了, 请和 @cosven 联系。

class feeluown.gui.uimodels.my_music.MyMusicUiManager(app)[source]

Note

目前,我们用数组的数据结构来保存 items,只提供 add_item 和 clear 方法。 我们希望,MyMusic 中的 items 应该和 provider 保持关联。provider 是 MyMusic 的上下文。

而 Provider 是比较上层的对象,我们会提供 get_item 这种比较精细的控制方法。

播放列表管理

class feeluown.gui.uimodels.playlist.PlaylistUiItem(obj=None, **kwargs)[source]

根据目前经验,播放列表的相关操作最基本的就是几个:

  • 创建、删除
  • 添加、移除歌曲
  • 重命名
  • 点击展示这个歌单

这些操作对各平台的播放列表、歌单来说,语义都是一致的, 所以 PlaylistUiItem 暂时不提供 clicked 等操作信号。

GUI 组件

class feeluown.gui.widgets.login.LoginDialog[source]

Base class for login dialogs

login_succeed

login succeed signal

class feeluown.gui.widgets.login.CookiesLoginDialog(uri: str = None, required_cookies_fields=None)[source]

CookiesLoginDialog provides a text edit area and a login button. User firstly fill in the text edit area with cookies. User can then click the login button. The clicked signal is connected to login().

Cookies can be in text or json format. User can copy cookies from web browser like Chrome or Firefox. Cookies copied from firefox are in json format and cookies copied from Chrome are in text format.

Subclass MUST implement four methods.

One usage example: feeluown-qqmusic.

get_cookies()[source]

Parse the content in text edit area

Returns:return None when the content is invalid
Return type:dict or None
show_hint(text, color=None)[source]

Show hint message on dialog

Parameters:color (string) – red for error, orange for warning, green for success
autologin()[source]

Try to load user cookies and login with it

Generally, you can call this method after dialog is shown.

setup_user(user)[source]

Setup user session

load_user_cookies()[source]

Load user cookies from somewhere

Load the cookies that is dumped before. If the load processing failed, just return None.

Returns:cookies in dict format
Return type:dict or None
dump_user_cookies(user, cookies)[source]

Dump user cookies to somewhere

Generally, you can store the cookies in FeelUOwn data directory with specifical filename.

login()[source]

Login with cookies

Read cookies that has been filled in and create a user from it. If succeed, the login_succeed signal will be emit. If failed, show specific error message on the dialog based on the exception.

user_from_cookies(cookies)[source]

Create a user model from cookies dict

Return type:feeluown.models.UserModel

异常

HELP: I do not know how to design exception classes, as a result, these interfaces can be changed frequently.

exception feeluown.excs.FuoException[source]
exception feeluown.excs.LibraryException[source]
exception feeluown.excs.ProviderIOError(message='', provider=None)[source]

Read/write data from/to provider failed

currently, all providers use requests to send http request, and many Requestexception are not catched, so ProviderIOError inherit RequestException.

exception feeluown.excs.CreateReaderFailed(message='', provider=None)[source]

(DEPRECATED) use ProviderIOError instead

exception feeluown.excs.ReaderException(message='', provider=None)[source]

(DEPRECATED) use ProviderIOError instead

exception feeluown.excs.ReadFailed(message='', provider=None)[source]

(DEPRECATED) use ProviderIOError instead

exception feeluown.excs.ResourceNotFound[source]
exception feeluown.excs.ProviderAlreadyRegistered[source]
exception feeluown.excs.ProviderNotFound[source]
exception feeluown.excs.ModelNotFound[source]

Model is not found

For example, a model identifier is invalid.

New in version 3.7.7.

exception feeluown.excs.NotSupported[source]

Provider does not support the operation

exception feeluown.excs.MediaNotFound[source]
exception feeluown.excs.NoUserLoggedIn[source]

(DEPRECATED) return None when there is no user logged in

(Deprecated) 媒体资源管理 v1

feeluown 一个设计目标是让用户能够合理整合并高效使用自己在各个音乐平台能获取的资源。 而每个平台提供资源数据的方式都有差异。有的可能已经公开的 RESTful API,它可以获取到资源的元信息, 并且也有接口可以获取到资源的链接;而有的平台则没有公开的可用接口,但是通过一些技术手段(如爬虫), 也可以获取到平台的资源。另外,每个平台的资源模型也有差异。有的平台会用一个非常大的结构体来表示一首歌曲; 而有的平台会有很多个小结构体,来拼凑出一首歌曲的全部信息。这些平台的差异给 feeluown 的架构设计带来了一些挑战, feeluown 通过“媒体资源管理”子系统来解决这些困难。

音乐库是媒体资源管理子系统的入口。音乐库部分负责管理 feeluown 的音乐资源,包括歌曲、歌手、专辑详情获取, 专辑、歌单封面获取及缓存(这是设计目标,部分逻辑目前未实现)。它主要由几个部分组成: 音乐对象模型(Model)、音乐提供方(Provider)、提供方管理(Library)。

+-------------------------------------------------------------------------+
|  +---------+                                                            |
|  | Library |                                                            |
|  +---------+                +--------+                                  |
|   |                         | Models |                                  |
|   |  +-------------------+  | Song   |                                  |
|   |--| provider(netease) | -| Artist |----                              |
|   |  +-------------------+  | Album  |   |             +--------------+ |
|   |                         | ...    |   |             | Model Spec   | |
|   |                         +--------+   | duck typing | (Base Model) | |
|   |                                      |-------------|              | |
|   |                      +--------+      |             | BaseSong     | |
|   |  +-----------------+ | Models |      |             | BaseArtist   | |
|   |--| provider(xiami) |-| Song   |-------             | ...          | |
|   |  +-----------------+ | ...    |                    +--------------+ |
|   |                      +--------+                                     |
|   |                                                                     |
|   |--...                                                                |
|                                                                         |
+-------------------------------------------------------------------------+

音乐库

音乐库模块管理资源提供方(Provider)。

# 注册一个资源提供方
library.register(provider)

# 获取资源提供方实例
provider = library.get(provider.identifier)

# 列出所有资源提供方
library.list()

# 在音乐库中搜索关键词
library.search('linkin park')

资源提供方

歌曲等音乐资源都来自于某一个提供方。比如,我们认为本地音乐的提供方是本地, 网易云音乐资源的提供方是网易,等等。对应到程序设计上,每个提供方都对应一个 provider 实例。 provider 是我们访问具体一个音乐平台资源音乐的入口。

在 feeluown 生态中,每个音乐资源提供方都对应着一个插件,我们现在有 feeluown-local/feeluown-netease 等许多插件,这些插件在启动时,会注册一个 provider 实例到 feeluown 的音乐库模块上。 注册完成之后,音乐库和 feeluown 其它模块就能访问到这个提供方的资源

举个栗子,feeluown-local 插件在启动时就创建了一个 identifierlocal 的 provider 实例, 并将它注册到音乐库中,这样,当我们访问音乐库资源时,就能访问到本地音乐资源。

这个过程抽象为代码的话,它就类似:

result = library.serach()
# we will see nothing in result because library has no provider

from fuo_local import provider
library.register(provider)

result = library.search('keyword')
# we may see that some local songs are in the search result

每个 provider 实例,它都需要提供访问具体资源的入口。举个栗子,

from fuo_local import provider

# we can get a song instance by a song identifier
song = provider.Song.get(song_id)

# we can also get a artist instance by a artist identifier
artist = provider.Artist.get(artist_id)

下面是音乐资源提供方的抽象基类,我们推荐大家基于此来实现一个 Provider 类。

当我们访问一个音乐提供方的资源时,不同的用户对不同资源的权限也是不一样的。 如果我们需要以特定的身份来访问音乐资源,我们可以使用 provider 的 authauth_as 方法:

from fuo_netease import provider
user_a = obj  # UserModel
provider.auth(user_a)

# 使用 auth_as 来临时切换用户身份
with provider.auth_as(user_b):
   provider.Song.get(song_id)

资源模型

在 feeluown 中,我们为各种音乐资源定义了各自的模型标准, 每个资源提供方的创建的资源实例都应该遵守这个标准。

模型类型

我们预定义的音乐资源相关的模型有 6 种:歌曲,歌手,专辑,歌单,歌词,用户。

class feeluown.models.ModelType[source]

An enumeration.

dummy = 0
song = 1
artist = 2
album = 3
playlist = 4
lyric = 5
video = 6
user = 17
comment = 18
none = 128

模型基类及其元信息

这几种模型定义都继承于一个模型基类 BaseModel 。 而每个模型都会有自己的的元信息,比如:这个模型是什么类型?有哪些字段? 有哪些方法?这些元信息都会记录在模型的 inner class Meta 中。

class feeluown.models.BaseModel(obj=None, **kwargs)[source]

Base model for music resource

class Meta

模型元信息。模型的实例可以通过 meta 属性来访问模型元信息。 此 Meta 类的设计借鉴于 Django Model Meta Options

model_type

模型类型,默认为 ModelType.dummy

fields

模型的字段,所有模型都必须有一个 identifier 字段

除了类型和字段这两个基本的元信息之外,模型还会有一些其它的元信息也会被记录在 Meta 类中,不同类型的模型,元信息可能也会不同,后面我们会陆续介绍。

一个模型示例

我们以 歌曲模型 为例,来看看一个真正的模型通常都由哪些部分组成:

# 继承 BaseModel
class SongModel(BaseModel):

    # 定义歌曲模型的元信息
    class Meta:
        # 类型为 ModelType.song
        model_type = ModelType.song

        # 定义模型字段
        fields = ['album', 'artists', 'lyric', 'comments', 'title', 'url',
                  'duration', 'mv']

        # 定义模型展示字段
        fields_display = ['title', 'artists_name', 'album_name', 'duration_ms']

    # 除了上述定义的模型字段外,歌曲模型实例也总是会有下面三个 property
    @property
    def artists_name(self):
        return _get_artists_name(self.artists or [])

    @property
    def album_name(self):
        return self.album.name if self.album is not None else ''

    @property
    def duration_ms(self):
        if self.duration is not None:
            seconds = self.duration / 1000
            m, s = seconds / 60, seconds % 60
        return '{:02}:{:02}'.format(int(m), int(s))

这个模型有几个方面的意义:

  1. 它定义了该模型的类型(model_type) 为 ModelType.song

  2. 它定义了一首歌曲应该有哪些字段(fields)。在这个例子中,也就是: album, artists, lyric, comments, title, url , duration, mv 8 个字段。

    另外,它还有 artists_name, album_name, duration_ms 这 3 个属性。

    其它模块在使用 model 实例时,总是可以访问这 8 个字段以及 3 个属性。 举个例子,在程序的其它模块中,当我们遇到 song 对象时,我们可以确定, 这个对象一定 会有 title 属性 。这也要求资源提供方在实现它们的资源模型时, 要严格按照规范来进行。

  3. 它定义了一首歌曲的展示字段(fields_display)。我们在后面会详细介绍它。

访问模型字段

模型定义了一个模型实例应该具有哪些字段,但访问实例字段的时候, 我们需要注意两个问题:

1. 字段的值不一定是可用的 。比如一首歌可能没有歌词, 也没有评论,这时,我们访问这首歌 song.lyric 时,得到的就是一个空字符串, 访问 song.comments 属性时,得到的是一个空列表,但不会是 None 。

2. 第一次获取字段的值的时候可能会产生网络请求 。以歌曲为例, 我们只要 identifier 就可以实例化一个歌曲模型,这时它的 url/lyric 等字段值都没有, 当我们第一次获取歌曲 url , lyric , comments 等属性时, model 可能会触发一个网络请求,从资源提供方的服务端来获取资源的信息。 也意味着简单的属性访问可能会触发网络或者文件 IO

Note

访问模型字段和访问一个 python 对象属性不同,它里面有非常多的黑魔法。 这些黑魔法对我们来说有利有弊,这样设计的缘由可以参考 怎样较好的抽象不同的资源提供方?

黑魔法:我们重写了 BaseModel 的 __getattribute__ 方法, 当我们访问实例的一个字段时,如果这个字段值为 None (没有初始化的字段的值都是 None), 实例会调用自己模型的 get 类方法来初始化自己,我们认为 get 方法可以初始化所有字段 (前面我们也提到 get方法应该尽可能初始化所有字段,get 方法往往是一次 IO 操作,比如网络请求), 调用 get 方法后,从而进入下一 生命阶段 gotten。 这样,调用方在访问这个字段时,就总是能得到一个初始化后的值,

模型实例生命阶段更多细节可以参考 实例生命周期

class feeluown.models.BaseModel(obj=None, **kwargs)[source]

Base model for music resource

__getattribute__(name)

获取 model 某一属性时,如果该属性值为 None 且该属性是 field 且该属性允许触发 get 方法,这时,我们尝试通过获取 model 详情来初始化这个字段,于此同时,还会重新给部分 fields 重新赋值。

模型的展示字段

对于一首歌曲而言,我们认为 歌曲标题+歌手名+专辑名+时长 可以较好的代表一首歌曲, 因为用户(人类)看到这四个字段,往往就能大概率的确定这是哪首歌。如果只有歌曲标题, 我们则不那么确定,因为一首歌可能被许多歌手唱过;只有标题和歌手名,我们同样不能确定。

不像计算机,或者说软件,它会使用 identifier 字段来标识区分一个资源,而用户(人类) 往往会通过一些 可读的 特征字段来标识一个资源,软件在展示一个资源的时候, 往往主要也会展示这些 特征 字段 ,我们将这些字段称之为 展示字段

对于一个模型来说,展示字段会有一个展示值,我们可以在字段后加上 _display 来访问展示值。比如访问歌曲标题的展示值: song.title_display , 歌曲的歌手名称的展示值: song.artists_name_display

展示字段的展示值有一些特点:

  1. 展示值的类型总是字符串
  2. 访问展示值总是安全的,不会触发网络请求。当值为空时,返回空字符串。
  3. 展示值和对应字段真正的值可能不一样(从第 2 点可以推断出来)

分页读

服务端提供大数据集时往往会采用分页技术。对于服务端(API),接口一般有两种设计:

  1. offset + limit
  2. page + pageSize

这两种设计没有根本区别,只是编程的时候会不同,一般来说,大家认为 offset + limit 更直观。 而对于前端或者说客户端,UX(User Experience) 一般有两种设计:

  1. 流式分页(常见于信息流,比如知乎的个人关注页)
  2. 电梯式分页(常见于搜索结果,比如百度搜索的结果页)

这两种设计各有优劣,在 UI 上,feeluown 目前也是使用流式分页。比如一个歌单有上千首, 则用户需要一直往下拉。在接口层面,feeluown 模块提供了 SequentialReader 来帮助实现流式分页。

class feeluown.utils.reader.SequentialReader(g, count, offset=0)[source]

Help you sequential read data

We only want to launch web request when we need the resource Formerly, we use Python generator to achieve this lazy read feature. However, we can’t extract any read meta info, such as total count and current offset, from the ordinary generator.

SequentialReader implements the iterator protocol, wraps the generator and store the reader state.

Note

iterating may be a blocking operation.

Usage example:

>>> def fetch_songs(page=1, page_size=50):
...     return list(range(page * page_size,
...                       (page + 1) * page_size))
...
>>> def create_songs_g():
...     page = 0
...     total_page = 2
...     page_size = 2
...
...     def g():
...         nonlocal page, page_size
...         while page < total_page:
...            for song in fetch_songs(page, page_size):
...                yield song
...            page += 1
...
...     total = total_page * page_size
...     return SequentialReader(g(), total)
...
>>> g = create_songs_g()
>>> g.offset, g.count
(0, 4)
>>> next(g), next(g)
(0, 1)
>>> list(g)
[2, 3]
>>> g.offset, g.count
(4, 4)

New in version 3.1.

流式分页存在一个问题,必须按照顺序来获取数据。而有些场景,我们希望根据 index 来获取数据。 举个例子,假设一个播放列表有 3000 首歌曲,在随机播放模式下,系统需要随机选择了 index 为 2500 的歌曲,这时候,我们不能去把 index<2500 的歌曲全部拉取下来。 feeluown 提供了 RandomReader 类来实现这个功能

class feeluown.utils.reader.RandomReader(count, read_func, max_per_read)[source]
__init__(count, read_func, max_per_read)[source]

random reader constructor

Parameters:
  • count (int) – total number of objects
  • read_func (function) – func(start: int, end: int) -> list
  • max_per_read (int) – max count per read, it must big than 0
read(index)[source]

read object by index

if the object is not already read, this method may trigger IO operation.

Raises:ReadFailed – when the IO operation fails
readall()[source]

read all objects

Return list:list of objects
Raises:ReadFailed
class feeluown.utils.reader.RandomSequentialReader(count, read_func, max_per_read=100)[source]

random reader which support sequential read

模型实例化

模型实例化有三种常见方法,它们分别适用于不同场景。

第一种:当我们知道资源的准确信息时,可以通过构造函数来创建一个实例:

song = SongModel(identifier=123,
                 title='Love Story',
                 url='http://xxx.mp3',
                 duration=1000.12)

资源提供方通常会使用这种方法来创建一个实例,因为资源提供方拥有准确且全面的信息。

第二种:我们也可以通过展示字段和 identifier 来创建一个资源实例

class feeluown.models.BaseModel[source]

Base model for music resource

classmethod create_by_display(identifier, **kwargs)

create model instance with identifier and display fields

以上面的歌曲模型为例,我们可以这样来创建一个歌曲模型实例:

identifier = 1
title = 'in the end'
artists_name = 'lp'  # linkin park
album_name = 'unknown'
# 如果不知道 duration,创建时可以忽略
song = SongModel.create_by_display(identifier,
                                   title=title,
                                   artists_name=artists_name,
                                   album_name=album)

assert song.title_display == 'in the end'

这时,我们并不需要知道歌曲特别准确的信息,只需要保证 identifier 准确皆可, 类似歌曲标题,我们可以不用在乎大小写;也不需要在意歌手名的全名。接着, 我们可以直接访问歌曲真实的标题:

print(song.title)   # 可能会触发网络请求

在获取到真实的标题之后,我们再次访问标题的展示值时,展示值会变成和真实值一样。

第三种:通过 Model.get 方法来创建(获取)一个资源实例。 资源提供方在实现自己的资源模型时,可以在模型的元信息种声明自己是否支持 get 方法, 如果支持,则实现 get 方法,get 方法返回的资源实例的字段应该 尽可能 全部初始化,访问它的任何字段都应该 尽可能 不触发网络请求。

class feeluown.models.BaseModel(obj=None, **kwargs)[source]

Base model for music resource

class Meta
allow_get

是否可以通过 get 来获取一个实例

classmethod get(identifier)[source]

get model instance by identifier

资源方应该尽可能实现 get 方法。

实例生命周期

根据模型字段初始化的状态,我们定义了实例的生命周期, 上述三种实例化方法创建的实例处于生命周期的不同阶段(stage)。

一个实例的生命周期由三个阶段组成: display, inited, gotten。 通过构造函数创建的实例所处的阶段是 inited, 通过 create_by_display 方法创建的实例所处阶段是 dispaly, 通过 get 方法获取的实例处于 gotten 阶段。

+---------+
| dispaly | ----
+---------+     \      +--------+
                 ----> | gotten |
+--------+      /      +--------+
| inited | -----
+--------+

当实例处于 display 阶段时,它所有的字段可能都没有初始化, 当实例处于 inited 阶段时,它的某些字段可能还没有被初始化, 一般情况下,没初始化的字段的值是 None。

当我们访问模型实例字段的时候,实例可能会进行状态切换,详情可以参考 访问模型字段

预定义的模型

class feeluown.models.BaseModel(obj=None, **kwargs)[source]

Base model for music resource

class Meta
model_type = ModelType.dummy
allow_get = True

Model should implement get method as far as possible

allow_batch = False
fields =
name type desc
identifier str model instance identifier
class feeluown.models.SongModel(obj=None, **kwargs)[source]
class Meta
model_type = ModelType.song
fields =
name type desc
album AlbumModel  
artists ArtistModel  
comments NOT DEFINED  
duration float song duration (unit: mileseconds)
lyric LyricModel  
mv MvModel  
title str title
url str song url (http url or local filepath)
fields_display = [title, artists_name, album_name, duration_ms]
artists_name
album_name
duration_ms
filename
class feeluown.models.ArtistModel(obj=None, **kwargs)[source]

Artist Model

class Meta
model_type = ModelType.artist
fields =
name type desc
albums list list of AlbumModel
cover str  
desc str  
name str  
songs list list of SongModel
allow_create_songs_g = False

是否允许创建歌曲生成器,如果为 True,我们可以调用 create_song_g 方法来创建一个歌曲生成器。

一个歌手可能会有上千首歌曲,这种情况下,一次性返回所有歌曲显然不是很合适。 这时,资源提供方应该考虑实现这个接口,调用方也应该考虑使用。

fields_display = [name]

NOT IMPLEMENTED

create_songs_g()[source]

create songs generator(alpha)

class feeluown.models.AlbumType[source]

Album type enumeration

中文解释:

Single 和 EP 会有一些交集,在展示时,会在一起展示,比如 Singles & EPs。
Compilation 和 Retrospective 也会有交集,展示时,也通常放在一起,统称“合辑”。

References:

  1. https://www.zhihu.com/question/22888388/answer/33255107
  2. https://zh.wikipedia.org/wiki/%E5%90%88%E8%BC%AF
standard = 'standard'
single = 'single'
ep = 'EP'
live = 'live'
compilation = 'compilation'
retrospective = 'retrospective'
guess_by_name = <bound method AlbumType.guess_by_name of <enum 'AlbumType'>>[source]
class feeluown.models.AlbumModel(*args, **kwargs)[source]
class Meta
model_type = ModelType.album
fields =
name type desc
artists list list of ArtistModel
cover str  
desc str  
name str  
songs list list of SongModel
type AlbumType  
artists_name
class feeluown.models.LyricModel(obj=None, **kwargs)[source]

Lyric Model

Parameters:
  • song (SongModel) – song which lyric belongs to
  • content (str) – lyric content
  • trans_content (str) – translated lyric content
class Meta
model_type = ModelType.lyric
fields =
name type desc
content str lyric text
trans_content str translated lyric text
song SongModel the related song
class feeluown.models.MvModel(obj=None, **kwargs)[source]
class Meta
fields =
name type desc
artist ArtistModel  
cover str  
desc str  
media Media  
name str  
class feeluown.models.PlaylistModel(obj=None, **kwargs)[source]
class Meta
model_type = ModelType.playlist
fields =
name type desc
cover str playlist cover url
desc str  
name str  
songs list  
allow_create_songs_g = False
add(song_id)[source]

add song to playlist, return true if succeed.

If the song was in playlist already, return true.

remove(song_id)[source]

remove songs from playlist, return true if succeed

If song is not in playlist, return true.

class feeluown.models.SearchModel(obj=None, **kwargs)[source]

Search Model

TODO: support album and artist

class Meta
fields =
name type desc
q str search query string
songs list  
class feeluown.models.UserModel(obj=None, **kwargs)[source]

User Model

Parameters:
  • name – user name
  • playlists – playlists created by user
  • fav_playlists – playlists collected by user
  • fav_songs – songs collected by user
  • fav_albums – albums collected by user
  • fav_artists – artists collected by user
class Meta
model_type = ModelType.user
fields =
name type desc
name str  
fav_albums list list of AlbumModel
fav_artists list  
fav_playlists list playlists collected by user
fav_songs list  
playlists list playlists created by user
allow_fav_songs_add = False
allow_fav_songs_remove = False
allow_fav_playlists_add = False
allow_fav_playlists_remove = False
allow_fav_albums_add = False
allow_fav_albums_remove = False
allow_fav_artists_add = False
allow_fav_artists_remove = False
add_to_fav_songs(song_id)[source]

add song to favorite songs, return True if success

Parameters:song_id – song identifier
Returns:Ture if success else False
Return type:boolean
remove_from_fav_songs(song_id)[source]
add_to_fav_playlists(playlist_id)[source]
remove_from_fav_playlists(playlist_id)[source]
add_to_fav_albums(album_id)[source]
remove_from_fav_albums(album_id)[source]
add_to_fav_artists(aritst_id)[source]
remove_from_fav_artists(artist_id)[source]

资源文件

在 feeluown 中, media 代表媒体实体资源,我们称 media 为资源文件。 上面我们有讲到歌曲和 MV 等资源模型,一个资源模型可以对应多个资源文件, 比如一首歌曲可以有多个音频文件、或者多个链接,这些音频文件的质量可能不一样(高中低), 文件格式可能也不一样(mp3,flac,wav)等。

在 feeluown 中,我们定义了三种媒体资源类型:音频,视频,图片。

class feeluown.media.MediaType[source]
audio = 'audio'
video = 'video'
image = 'image'

每个资源都有特定的质量,对于音频,我们一般根据比特率来判断;对于视频, 我们根据分辨率来判断;对于图片,我们目前还没有设定标准。

在 feeluown 中,我们 约定 比特率 为 320kbps 的音频文件质量为 hq (high quality), 大于 320kbps 的为 shq (super high quality),一般是无损音乐,200kbps 左右的音频为 sq (standard quality), 比特率小于 200kbps 的音频质量为 lq (low quality)。

class feeluown.media.Quality.Audio

An enumeration.

hq = 'hq'
lq = 'lq'
shq = 'shq'
sq = 'sq'

对于视频,根据视频分辨率来定义文件质量。规则如下:

分辨率 品质
4k fhd (full high definition)
720p ~ 1080p hd (high definition)
480p sd (standard definition)
<480p ld (low definition)
class feeluown.media.Quality.Video

An enumeration.

fhd = 'fhd'
hd = 'hd'
ld = 'ld'
sd = 'sd'

当资源提供方提供的资源有多种质量时,比如一首歌有多个播放链接,我们可以让 SongModel 继承 MultiQualityMixin 类,并实现 list_qualityget_media 两个方法:

class feeluown.media.MultiQualityMixin[source]
list_quality()[source]

list available quality

select_media(policy=None)[source]

select a media by quality(and fallback policy)

Parameters:policy – fallback priority/policy
get_media(quality)[source]

get media by quality

if q is not available, return None.

select_media 方法的参数为 policy,policy 是一个符合一定规则的字符串, 由 SortPolicy 类负责解析。

>>> policy = '>>>'
>>> media = song.select_media(policy)
>>> if media is None:
>>>     player.stop()
>>> else:
>>>     player.play(media)

SortPolicy 类定义了 6 中规则,见如下的 rules 变量文档。

class feeluown.media.Quality.SortPolicy

media sort policy

For example, when the quality list is: [hp, h, s, l], then policy will be interpreted like this:

h<<> = h -> hp -> s  -> l
h>>> = h -> s  -> l  -> hp
h><  = h -> s  -> hp -> l
h<>  = h -> hp -> s  -> l
>>>  = hp -> h -> s  -> l  # doctest: +SKIP
<<<  = l -> s -> h -> hp

Code Example:

policy = 'hq<>'  # priority: hq shq sq lq
song.select_media(policy)
song.select_media('>>>')  # shq hq sq lq

video_policy = 'sd<>'  # priority: sd hd ld fhd
video.select_media(video_policy)
rules

policy 字符串规则。这里 rlrl 的意思是 right left right left, 它对应的规则是 r'(\w+)><' 规则中 \w 匹配的是质量字符串, > 代表向右 right< 代表向左 left

举个例子,对于策略 hq>< ,我们可以这样理解:我们有一个从高到低的排好序的列表 [shq, hq, sq, lq] , 以 hq 为中心,先向右看,为 sq,再向左看一位, 为 shq, 重复向右和向左看的逻辑,就可以得到这样一个优先级: hq -> sq -> shq -> lq

rules = (
    ('rlrl', r'(\w+)><'),
    ('lrlr', r'(\w+)<>'),
    ('llr', r'(\w+)<<>'),
    ('rrl', r'(\w+)>><'),
    ('rrr', r'(\w+)?>>>'),
    ('lll', r'(\w+)?<<<'),
)

media 对象中包含了资源文件的元信息,对于音频文件,有 bitrate, format (以后会根据需要添加新属性,比如 size),这个元信息保存在 media.metadata 中, metadata 是 AudioMeta 的实例。对于视频文件,metadata 则是 VideoMeta (暂时未实现) 的实例。

class feeluown.media.AudioProps(bitrate=None, format=None)[source]

使用示例

媒体资源管理 v2

媒体资源管理对应的代码模块主要是 feeluown.library 包。

经典话题

音乐多音质

fuo 协议

fuo 协议 是 feeluown 播放器的控制协议,它主要是用来提升 feeluown 的可扩展性。

fuo 协议在设计时优先考虑以下几点:

  1. 可读性好
  2. 请求简单(使用 netcat 工具可以轻松发送请求)
  3. 解析代码简单

feeluown 以默认参数启动时,会相应的启动一个 TCP 服务,监听 23333 端口, 我们可以使用 fuo 协议,通过该 TCP 服务来与播放器通信。比如:

目前(3.0 版本),协议主要包含两个方面的内容:

  1. 资源标识:比如 fuo://netease/songs/289530 标识了一首歌
  2. 命令控制:播放、暂停、下一首、执行代码等控制命令

资源标识

feeluown 使用 design-library 来管理音乐提供方的资源,接入音乐库的资源都有一个特征, 这些资源都有一个唯一的资源标识符,我们称之为 fuo uri

对于大部分 fuo uri 来说,它们由几个部分组成:scheme, provider, type, identifier。 scheme 统一为 fuo。

fuo://{res_provider}/{res_type}/{res_identifier}
         |              |              |
     资源提供方 id     资源类型         资源id

举个例子:

  • fuo://local/songs/12 代表的资源就是 本地 (资源提供方) 提供的 id 为 12歌曲 (资源类别)。
  • fuo://netease/artists/46490 代表的资源时 网易云音乐 提供的 id 为 46490歌手

资源提供方都是以插件的形式存在,目前已知的资源插件有 本地音乐(local)、qq 音乐(qqmusic)、 虾米音乐(xiami)、网易云音乐(netease),我们可以在 这里 找到它们。 实现一个资源提供方也很简单,有兴趣的话,可以参考上面的例子。

目前支持的资源类型有:

  • 用户: fuo://{provider}/users/{identifier}
  • 歌曲: fuo://{provider}/songs/{identifier}
  • 歌手: fuo://{provider}/artists/{identifier}
  • 专辑: fuo://{provider}/album/{identifier}
  • 歌词: fuo://{provider}/songs/{identifier}/lyric

注:除了这类标识资源的 uri,我们以后可能会有其它格式的 uri。

带有简单描述的资源标识符

一个 uri 虽然可以标识一个资源,但是 uri 能够携带的信息往往不够,客户端拿到 uri 后, 肉眼识别不出来这是个什么东西,所以我们可以给 uri 附上一个简短描述,对于 歌曲 来说:

{song uri}{      }# {title}-{artists_name}-{album_name}-{duration_ms}
            |      |                   |
      空格或者\t  分割符#       描述(所有字段均为可选,但必须按照顺序)

用户,歌手,专辑格式定义分别如下:

{user uri}       # {name}
{artist uri}     # {name}
{album uri}      # {album_name}-{artist_name}

注:之后可以考虑支持带有复杂描述的资源标识符,可能类似(待研究和讨论)

fuo://{provider}/songs/{identifier} <<EOF
  title:     hello world
  artist:    fuo://{provider}/artists/{identifier}  # {name}
  album:     fuo://{provider}/artists/{identifier}  # {album_name}-{artists_name}
  duration:  123.01
  url:       http://xxx/yyy.mp3
EOF

RPC 服务

fuo 协议定义了一些语义化的命令,客户端和 fuo (服务端) 可以通过一问一答的方式来进行通信。 (注:fuo 协议不依赖 TCP 传输,不过目前 feeluown daemon 启动时只提供了基于 tcp 的 transport。)

在客户端连接上服务端时,服务端会建立一个会话,并发送类似 OK feeluown 1.0.0\n 的信息, 客户端接收到这个消息后,就可以正常的发送请求了。在一个会话中,服务端会依次响应接收到的请求。

注:下面写的很多设计目前都没有实现,算是一个 RFC 草稿。

消息(Message)

和 HTTP 消息类似,fuo 消息是 feeluown 服务端和客户端之间交换数据的方式。 有两种类型的消息:

  • 请求:由客户端发送用来触发服务端的一个动作
  • 响应:服务端对请求的应答

请求(Request)

请求可以是一行文本,也可以是多行。

当请求为一行文本时,它以 \r\n 或者 \n 结束,具体结构如下:

{cmd}    {param} [{options}]        #:   {req_options}  \r\n
  |        |         |             |         |
命令      参数    命令选项          分隔     请求选项

这一行,我们称之为 request-line 。其中, 命令是必须项。参数和命令选项都是视具体命令而定,有的是可选,有的则必须提供。 命令选项由 [] 包裹,选项都是 key-value 形式,比如 [artist="linkin park",json=true]

举几个例子:

# 查看服务端状态,只需要提供命令即可
status

# 播放一首歌曲,必须提供一个参数
play fuo://local/songs/1
play "晴天 - 周杰伦"

# 搜索关键字为晴天、歌手为周杰伦、来源为网易云的歌曲
# 搜索命令必须提供一个参数,命令选项可选
# (注:该功能目前还未实现,欢迎 PR)
search 晴天 [artist=周杰伦,source=netease]

请求选项由 #: 与命令选项分隔。而请求选项格式和命令选项格式是相同的, 都是 key=value 形式。在我们设计中,请求选项可能包含以下(目前均未实现,欢迎 PR):

  • 输出格式: format=json
  • 分页输出: less=true 可以简写为 less

举几个例子:

# 搜索纵观线关键字,结果可以分多次返回(设置了请求选项)
# 这里 less 请求选项是 less=true 的简写
search 纵贯线  #: less

# 使用 JSON 格式返回
search 纵贯线 #: format=json,less

请求消息也可以是多行文本,使用多行文本时,需要遵守下面的格式(类似 bash here document)

{cmd} [{options}]  #: {req_options} <<EOF
document
EOF

在多行文本表示的命令中,document 即是命令的参数,这种命令只能接收一个参数。 举个例子

# 让服务端执行代码
exec <<EOF
print('hello, feeluown')
player.pause()
EOF

# 它基本相当于
exec "print('hello, feeluown'); player.pause()"

响应(Response)

响应体分为两个部分:头(status-line) 和内容(body),以 \r\n 为一个响应的结束。

: 头是响应体的第一行。头中会告诉客户端请求成功或者失败,body 长度,请求选项。 客户端应该根据 length 信息来拆分响应。

# 成功
ACK ok {length} #: more,json
{body}

# 失败
ACK oops {length}
{err_type}: {err_msg}

# 示例
ACK ok 0

下面是目前支持的所有命令:

命令 意义 示例
status 播放器当前状态 status
play 播放一首歌曲 play fuo://xiami/songs/1769099772
pause 暂停播放 pause
resume 恢复播放 resume
toggle 暂停/恢复 toggle
stop 停止播放 stop
next 下一首 next
previous 上一首 previous
search 搜索 search "我家门前有大海 - 张震岳"
show 展示资源详情 show fuo://xiami/songs/1769099772
list 显示当前播放列表 list
clear 清空当前播放列表 clear
remove 从播放列表移除歌曲 remove fuo://xiami/songs/1769099772
add 添加歌曲到播放列表 add fuo://xiami/songs/1769099772
exec 执行 Python 代码 exec <<EOF\n print('hello world') \nEOF

消息服务

除了 RPC 服务外,FeelUOwn 默认还会启动一个消息服务,其监听端口为 23334。FeelUOwn 会通过该服务来向外发送实时消息。

该服务的通信协议 1.0 版本设计非常简单,主要关注可读性,只用在“观看实时歌词” 的功能中。即客户端连接到 23334 端口后,发送 sub topic.live_lyric 加换行符到服务端, 客户端即可收到实时的歌词文本流。

在 FeelUown v3.8.3 版本之后,它提供 2.0 版本的通信协议。在 2.0 版本中, 消息有固定的结构,客户端可以根据该结构来拆分消息

MSG {topic} {body_length}\r\n
{body}
\r\n

客户端可以在连接建立之后,通过 set 指令来切换通信协议。2.0 版本协议更关注 机器的“可读性”,复用了 RPC 服务的 2.0 版本通信协议的格式设计。

相关专业术语

下面是 feeluown 中项目使用的一些专业名词,这些专业名词会体现在文档、代码注释、 变量命名等各方面。

library
音乐库
provider

资源提供方,提供媒体媒体资源的平台。

比如 YouTube、网易云音乐、虾米等。

source

资源提供方标识。

在 feeluown 中,歌曲、歌手等资源都有一个 source 属性来标识该资源的来源。

media

媒体实体资源

Media 关注的点是“可播放”的实体资源。在 feeluown 中,Media 有两种类型, Audio 和 Video (以后可能会新增 Image),它用来保存资源的元信息, 对于 Audio 来说,有 bitrate, channel count 等元信息。对于 Video 来说,有 framerate, codec 等信息。

和 Song 不一样,Song 逐重强调一首歌的元信息部分,比如歌曲名称、歌手等。 理论上,我们可以从 Media(Audio) 中解析出部分 Song 信息。

FeelUOwn
项目名,泛指 FeelUOwn 整个项目。从 GitHub repo 角度看,它指 github.com/feeluown 目录下所有的项目。
feeluown
包名(Python 包),程序名。从 GitHub repo 角度看, 它特指 github.com/feeluown/feeluown 这个项目。
fuo
程序名

一些调研与思考

怎样较好的抽象不同的资源提供方?

feeluown 的一个主要目标就是将各个资源进行抽象,统一上层使用资源的方式。 但各个资源提供方提供的 API 差异较大,功能差别不小,比如网易云音乐 会提供批量接口(根据歌曲 id 批量获取歌曲详情),而虾米和 QQ 音乐就没有 类似接口,这给 feeluown 的实现和设计带来了挑战。

问题一:同一平台,不同接口的返回信息有的比较完整,有的不完整

我们以网易云音乐的 专辑详细信息接口搜索接口 为例。 它的搜索接口返回的专辑信息大致如下:

"album": {
    "artist": {
        "id": 0,
        "alias": [],
        "img1v1": 0,
        "name": "",
        "picUrl": null,
        "picId": 0,
    },
    "id": 2960228,
    "name": "\u5218\u5fb7\u534e Unforgettable Concert 2010",
    "picId": 2540971374328644,
    ...
}

它没有专辑封面的链接,也没有专辑歌曲、歌手信息也不完整信息。 而专辑详细信息接口中,它就有 songs , artists , picUrl 等信息。

面对这个问题,目前有两种解决方案:

  1. 将搜索接口返回的 Album 定义为 BriefAlbumModel,将详细接口返回的定义为 AlbumModel
    • pros: 清晰明了,两者有明显的区分
    • cons: 多一个 Model 就多一个概念,上层要对两者进行区分,代码更复杂
  2. 定义一个 AlbumModel,创建 Model 实例的时候不要求所有字段都有值, 一些字段的值在之后在被真正用到的时候再自动获取。
    • cons: 比较隐晦
    • cons: 上层不也方便确认哪些字段是已经有值了,哪些会在调用的时候获取

Note

UPDATE 2019-05-04: 第二种方案使用已经半年了,我们发现它有一个让人头疼的问题: 类似 model.xxx 这样的代码可能会导致整个线程 block,而对于一个 GUI 程序来说, block(主)线程是不可接受的。为了不阻塞,我们使用的方案是让 model.xxx 这个操作跑在另一个线程中,这样的代码目前在 songs_table_container.py 中有较多使用。但这样的代码看起来很丑,性能也比较差(见 research/bench_getattr.py )。 另外,尽管我们在开发 feeluown 的时候可以额外的注意,让程序不因此卡住, 但是其它插件开发者并不一定完全了解这个机制,很容易写成“坏”的代码。

为了让整体代码更简单,目前使用的是第二种方案。上层假设 identifier/name 等字段是一开始就有了,url/artists 等字段需要之后调用接口才会有值。 (尽管这种方案看起来也有明显的缺点,但目前看来可以接受,也没想到更好的方法。欢迎大家讨论新的方案)。

问题二:不同平台,同一接口的返回信息有的比较完整,有的不完整

在虾米音乐中,在获取歌曲详细信息的时候,就可以获取这歌曲的播放链接。 但是在网易云音乐,需要单独调用一个接口来获取歌曲的播放链接。

在虾米音乐的 API 中,要获取一个歌手的所有信息,我们需要调用它的多个接口: 一个是歌手详情接口;另一个是歌手歌曲详情接口;还有歌手专辑接口等。

问题三:平台能力方面和开发体验

另一方面,就算各音乐平台都提供一样的 API,开发者在开发相关插件的时候, 也不一定会一次性把所有功能都完成,那时,也会存在一个问题: A 插件有某功能,但是 B 插件没有。

所以,当 B 插件没有该功能的时候,系统内部应该怎样处理?又怎样将该 问题呈现给用户呢?

举个例子,对于网易云音乐来说,它的批量获取歌曲功能可以这样实现:

NeteaseSongModel.list(song_ids): -> list<SongModel>

但是我们不能给虾米音乐和 QQ 音乐实现这样的功能,那怎么办, 目前有如下方法:

1. XiamiSongModel.list(song_ids): -> raise NotSupportedError
2. XiamiSongModel.list -> AttributeError  # 好像不太优雅
3. XiamiSongModel.allow_batch -> 加一个标记字段

目前使用的是第三种方案,加一个标记字段, allow_getallow_batch

对各种哲学的一些认识

鉴于在生活的不同阶段接对同一问题的理解一般都会有点不一样, 所以我把自己的想法加上时间戳。

Unix 哲学

2018-7-15 @cosven:

  1. 只做一件事,并把它做好
  2. 与其它程序可以良好组合

EAFP or LBYL

2020-12-26 @cosven:

在 FeelUOwn 中,每个 provider 提供的能力,对它们进行抽象时,有两种方式

  1. 假设 provider 提供了我们需要的所有能力,没有的时候,报错
  2. 要求提供方声明自己具有哪些能力,library 调用时先判断

FeelUOwn 大部分情况选用的是方式 2,举个例子,FeelUOwn 如果知道 provider 没有 A 功能, 就可以在界面上将这个功能的按钮置位灰色。而当该这功能对界面展示影响甚微时, 会考虑使用方式 1。

代码风格

注释

  • 注释统一用英文(老的中文注释应该逐渐更改)
  • docstring 使用 Sphinx docstring format
  • FIXME, TODO, and HELP - FIXME: 开发者明确知道某个地方代码不优雅 - HELP: 开发者写了一段自己不太理解,但是可以正确工作的代码 - TODO: 一些可以以后完成的事情 - 暂时不推荐使用 NOTE 标记

命名

测试

  • feeluown 包相关代码都应该添加相应测试

错误处理

日志和提示

特殊风格

  • 标记了 alpha 的函数和类,它们的设计都是不确定的,外部应该尽少依赖

  • Qt Widget 的子类的 UI 相关设置初始化应该放在 _setup_ui 函数中
  • 信号的 slot 方法应该设为 protected 方法
  • 类的 public 方法放在类的前面,protected 和 private 方法放在类的最后面, _setup_ui 函数除外
  • QWidget 子类最好不要有 async 方法,因为目前无法很好的为它编写相关单元测试

贡献力量

各位老师、同鞋,非常高兴你能点开这个文档~

在说怎样贡献代码之前,我们先啰嗦一下 FeelUOwn 项目的 主要目标

项目背景及目标

FeelUOwn 这个项目从 2015 年初开发到现在,已经 4 年有余, 从功能角度看,它已经具备最基本的功能,如本地歌曲扫瞄、歌曲播放、MV 播放、歌词。 基本模块也比较齐全,配置、插件、音乐库、远程控制等。

一开始,FeelUOwn 解决的问题是 Linux 上听在线音乐难。和现在百花齐放的生态不一样, 当年(=。=),Linux 上大部分播放器都只支持播放本地音乐,并且经常出现乱码现象。 我了解到的当时能听在线音乐的播放器有 deepin-music, 虾米电台等播放器。所以, 自己选择开发这个软件,更多历史请看这几篇博客 文章1文章2

如今,当时的问题已经不复存在,FeelUOwn 主要目标之一是提升用户听音乐的体验。 具体来说,主要体现以下几个方面:

  1. 让用户听任何想听的音乐
  2. 辅助用户能够发现、享受音乐

另外,FeelUOwn 也尽力成为一个程序员最友好的音乐播放器。这主要体现在这些点:

  1. 可扩展性强
  2. 项目工程化
  3. 尊重 Unix 习俗

可以做哪些贡献?

我们为 FeelUOwn 项目做贡献时,也都会围绕这些目标来进行。

首先,如果大家自己觉得播放器缺少了功能或者特性,可以提 Issue,有好的想法, 也可以提,我们非常期待大家的建议和想法。如果大家自己有需求,并且自己有能力动手实现, 我们也建议先在 Issue 上或者交流群进行简单讨论,然后进行代码实现。

用户会通过 Issue 或者交流群来提出的需求或想法,我们会把它们都收集, 记录在 FeelUOwn GitHub 项目 中,其中有大大小小的 TODO, 大家如果对某个 TODO 有兴趣,就可以进行相应的开发, 开发之前最好可以在 Telegram 交流群中吼一声,或者和管理员(目前为 @cosven) 说一声, 这样,这个 TODO 会被移动到 In progress 这一栏中去,避免大家做重复的工作。

除了功能方面,我们也特别欢迎大家对项目代码进行改进、让项目更加工程化。 目前,FeelUOwn 有一些设计不优雅、或者性能较差的代码,一部分是我们可以发现的, 我们已经将其标记为 FIXME / TODO / DOUBT ;另外,有些设计不好的地方, 我们还没有特别明确(比如 PyQt 在 FeelUOwn 中的使用),大家如果对这些问题有兴趣, 欢迎 PR!另外,工程化方面,FeelUOwn 的文档、单元测试、打包等都可以更加完善, 欢迎感兴趣的朋友根据自己的爱好进行选择。

如何做贡献?

对于一些小的 bugfix, feature 开发, 文档补全等,大家可以自己动手,然后 PR。 对于大的特性开发或者改动,建议大家先将自己的想法整理成文字,提在 Issue 上, 和大家同步并讨论,之后再动手开发。

如果需要进行修改代码(包括文档等),可以参考 本地开发快速上手 , 代码风格请参考 代码风格 ,一些 FeelUOwn 架构设计相关的决策,可以参考 程序架构接口参考手册 等文档。

最后值得一提的是,我们有一个开发者/用户交流群(邀请链接在 README 中),大家可以加入群里, 有任何 相关 或者 有意义的问题 ,都可以在群里进行讨论,有任何疑问, 也可以在群里沟通。感谢大家为 FeelUOwn 做出的贡献!