折腾笔记

折腾笔记

飞牛音乐|给 NAS 上的音乐库做一个 Apple Music 风格的 Android 客户端 - 折腾笔记

2026-09-09

为什么要做这个

家里跑着一台飞牛(fnOS),音乐全存在上面,平时靠飞牛音乐自带的 Web 端管理。用久了,有几个点越来越别扭:

  • 听歌要先开浏览器进 NAS 页面,手机上始终没有一个趁手的客户端,想随手点开就听很难受;

  • 飞牛音乐的接口没有公开文档,网关是 /music/api/v1,每个请求还带一套自研签名,想自己接就得先从 Web 端把这套签名逆向出来;

  • 我心里想要的样子很明确:Apple Music 那种干净的大标题、红色主播放键、歌词居中的沉浸式页面。现成的没有,那就自己写一个。

于是有了这个项目:一个纯 Kotlin + Jetpack Compose 的 Android 客户端,直连 NAS 上的飞牛音乐服务,不做任何中转服务器,装完连上就能听。

长什么样

整体走 Apple Music 的设计语言:底部是「曲库 / 搜索 / 我的」三格悬浮玻璃 Dock,选中态是一颗跟手滑动的液态玻璃水珠;曲库页大标题下是歌曲 / 专辑 / 艺术家三段切换;播放页上半部分是封面、下半部分是控制区和歌词;歌词页采用「焦点句钉在屏幕正中」的形态,上下浏览时焦点行始终居中大字显示。

一共 8 个页面:登录、曲库(歌曲 / 专辑 / 艺术家)、搜索、播放、歌词、专辑详情、艺术家详情、我的 / 设置。整体是深色底 + 品牌红 #FA2D48,配上两团缓慢漂移的光斑做氛围。

飞牛音乐客户端部分界面
播放 / 歌词 / 专辑详情等更多界面

功能

曲库

  • 歌曲 / 专辑 / 艺术家三个 Tab,分页下拉加载;

  • 顶部 Hero 区:总曲目数 + 一键播放全部;

  • 推荐专辑、热门歌曲、全部歌曲几个区块;

  • 点专辑 / 艺术家进详情页,详情页内可直接播放。

搜索

  • 歌曲、专辑、艺术家、歌单四类结果;

  • 搜索历史支持单条删除或一键清空。

播放

  • ExoPlayer + MediaSession:后台播放、通知栏、锁屏、蓝牙耳机按键全部接通;

  • 播放队列、下一首播放、随机播放、单曲 / 列表循环;

  • 迷你播放器常驻底部 Dock 上方,点一下展开全屏播放页;

  • 音频流走 Range 请求,拖进度条直接 seek,不整段缓存。

歌词

  • 标准 LRC 解析:多时间标签、[offset:][ti:] 等元数据标签都处理了;

  • 焦点句大字钉在屏幕中央,整篇歌词可拖动浏览,松手跳播到对应句;

  • 背景是两团缓慢漂移的品牌色光斑,播放时有一点点呼吸感。

我的 / 设置

  • 收藏、最近播放(本地 DataStore 持久化);

  • 深色 / 浅色 / 跟随系统三档主题;

  • 应用内检查更新,下载 APK 后直接拉起安装。

怎么搭的

技术栈很常规,没有花活:

选型

UI

Jetpack Compose + Material3 + Navigation Compose

播放

Media3(ExoPlayer + MediaSession)

网络

OkHttp + kotlinx.serialization

图片

Coil(复用同一个 OkHttp 客户端)

存储

DataStore(会话、偏好、收藏、最近播放)

架构上只有一个原则:播放状态只有一个源头PlaybackService 里养着唯一的 ExoPlayer 实例,PlaybackController 通过 MediaController 把它的状态镜像成一个 StateFlow,所有页面都只读这一个流。这样迷你播放器和全屏播放页的进度、封面、播放态天然一致,不可能出现两处对不上的 bug。

网络这块最关键的是 FnosAuthInterceptor——签名逻辑不散落在每个请求里,而是放在 OkHttp 拦截器中、在请求真正发出去之前统一计算。好处是连 ExoPlayer 发起的音频 Range 请求也会自动过一遍签名,代码里不需要任何一处手工拼签名。

踩坑记录

逆向这套私有接口的过程中踩了不少坑,挑几个印象最深的记录一下:

1. 签名算法:Go 的转义和 Java 不一样

飞牛 Web 端会给每个请求加一个 authx 头,签名由「前缀常量 + clientKey + payload」拼装后哈希而来,前缀和 clientKey 是写死在 Web 端 JS 里的(代码仓库里有,这里不贴)。payload 按请求动词分两种:GET 是「规范化后的 query 字符串」,POST 就是线上实际发送的 JSON body 原文。

坑就坑在「规范化」这三个字。服务端是 Go 写的,用的是 url.Values.Encode(),和 Java 的 URLEncoder 行为不一致:Go 保留 -_.~,而 Java 会把 ~ 转义掉、还会多留一个 *。一开始直接用 URLEncoder,签名死活对不上,最后照着 Go 的语义手写了一个 queryEscape 才算通过。

2. GET 签名签的是「解码回来」的 query

更绕的是:GET 的 query 要先按 key 排序、转义拼成字符串,然后再 URL-decode 回原始形态,用解码后的字节去算哈希。这个「编码完再解码」的流程看着很荒谬,但不照着做签名就是错的。解析侧同理,遇到非法 UTF-8 序列还要能安全退回原字符串,否则一个坏参数就能让整个请求签名失败。

3. 登录接口不能带版本头

除登录外,其它路由都要带 X-Music-API: v1 头。一开始图省事在全局拦截器里统一加,结果登录直接被网关拒掉——登录握手时带这个头反而不认。最后规则改为:/user/password-login/user/auth-login 两个路径跳过。另外密码不是明文传输,而是 sha256(明文) 后再走登录。

4. ExoPlayer 的 Range 请求没有签名

音频流地址是普通的 http://…/track/stream?guid=xxx,ExoPlayer 会带着 Range 头分段拉取。默认数据源发出的请求没有 authx,服务端直接拒。解法是把 Media3 的 DataSource 换成 OkHttp 实现:

// app/build.gradle.kts
implementation("androidx.media3:media3-datasource-okhttp:1.x")

val okHttpDataSourceFactory = OkHttpDataSource.Factory(httpClient)
    .setReadTimeout(0) // 见下文坑 5
exoPlayer = ExoPlayer.Builder(context)
    .setMediaSourceFactory(DefaultMediaSourceFactory(okHttpDataSourceFactory))
    .build()

这样每一个字节范围请求都会过一遍签名拦截器,seek 也就顺理成章地支持了。

5. 通知栏封面图同样 403

封面图也要签名,App 内用 Coil 加载没问题(把同一个 OkHttp 客户端塞给 ImageLoader 即可)。但通知栏和锁屏上的封面是 MediaSession 自己去取的,默认的 loader 不带签名,结果就是通知栏封面一片空白。处理方式是提前用带签名的 OkHttp 把封面拉成 Bitmap,写进 MediaMetadata 再交给 MediaSession 展示,绕开系统 loader 的裸请求。

6. 明文 HTTP 与自签证书

飞牛默认在 5666 端口跑明文 HTTP,Android 9 之后默认禁止明文流量,需要在 network_security_config 里显式放行。走 HTTPS 的话,NAS 上多半是自签证书、CN 还对不上局域网 IP,所以客户端侧对信任全部证书和 hostname 校验做了放行。这是局域网内自用的妥协,如果放到公网,请务必换成正规证书。

7. 播放进度只能轮询

ExoPlayer 没有暴露 position 的 Flow,进度条只能在播放中定时去取。现在是 500ms 一次,配合 onPositionDiscontinuity 回调(用户拖动进度后立刻强制同步一次),实际手感看不出延迟。

8. 歌词的「中央焦点槽」

这个 UI 折腾得最久。目标效果是:焦点句永远大字停在屏幕正中,整篇歌词又是可拖动的列表,播放推进时列表自动滚动、让对应的行恰好经过中央。

实现上焦点行是一个固定槽位,歌词列表整体靠 Animatable 平移;拖动时用 browsing 标记切到「预览行」模式,焦点槽文字实时跟着变化,松手才真正触发跳播。中间踩过一个坑:歌词不满一屏时平移量会算出负值,导致最后几行永远滚不到屏幕中央——需要给平移边界留足余量。

接口一览

飞牛音乐的网关挂在 http://<NAS>:5666/music/api/v1 下,这个项目实际用到的接口如下:

用途

路径

说明

登录 / 登出

/user/password-login/user/logout

密码为 sha256,返回 token

当前用户

/user/me

会话校验

连通性探测

/initialization/state

登录页用来判断地址是否可达

曲目列表

/track/list

分页

专辑 / 艺术家列表

/album/list/artist/list

分页

全量艺术家

/artist/list-all

不分页,一次拉全

专辑 / 艺术家详情

/album/detail/artist/detail

按 guid

详情内曲目

/track/album-detail/list/track/artist-detail/list

歌单

/playlist/list/track/playlist-detail/list

搜索

/search/track/search/album/search/artist/search/playlist

四类结果

歌词

/lyric/list

返回 LRC 文本,客户端解析

封面

/static/cover?coverId=

需签名

音频流

/track/stream?guid=

需签名,支持 Range

返回体统一包一层 { code, msg, data }code 为 120001 时表示 token 失效,客户端捕获后跳回登录页重新登录。

发版

为了不用每次手动传包,写了个 release.sh:自增 versionCode、改 versionName → 构建 → 把 APK 拷到更新源目录 → 用 Python 重写 update.json(版本号、下载直链、更新说明)→ 校验产物大小。加 --install 参数时还会通过无线 ADB 直接装到测试机上,顺手把华为的两个安装确认弹窗也自动点了。App 内「设置 → 关于 → 检查更新」就是去拉这份 update.json,比对 versionCode 后提示下载安装。

项目地址

https://gitea.zwbcc.cn/zwbpc/fmusic

纯自用 + 兴趣项目,接口是逆向出来的,飞牛固件一旦更新、签名规则变了可能就需要同步调整,介意的话请先在小版本上试用。

记录于 2026-09-08,更新于 2026-09-09。