记一次音乐平台解析项目的开发历程
构建一个全能音乐服务器:从零到自动同步
在数字音乐时代,我们都曾梦想过一件事:拥有一个属于自己的、统一的、能跨平台同步的音乐库。一个能将我们散落在QQ音乐、网易云音乐的收藏与自己本地的高品质FLAC、Master音源完美融合的“终极媒体库”。
这个项目从一个简单的API脚本开始,最终演变成一个集成了Web界面、AI伴奏处理、元数据自动补全、Nginx安全流媒体和Navidrome歌单自动同步的复杂系统。
项目地址:music-api-server
阶段一:奠基 - 从 Flask 到 FastAPI 的架构演进
我们的起点很明确:需要一个后端的API服务。
1. 最初的选择:Flask 与多线程
我们最初选择了 Flask。轻量、简单,非常适合快速启动项目。我们很快就搭建起了main.py,并实现了几个核心功能:
- API密钥保护: 通过
@require_api_key装饰器,确保了服务的私密性。 - 后台下载: 我们遇到的第一个挑战就是“等待”。当用户请求一首在线歌曲时,API需要时间去下载保存到本地库。我们不能让前端“假死”等待。解决方案是经典的多线程 (
threading.Thread)。当一个下载请求(无论是单曲还是整个歌单)被触发时,主线程会立即返回一个202 Accepted(任务已接受)的响应,同时将真正的下载任务抛给一个新创建的后台线程去执行。
2. 性能瓶颈:为什么选择升级到 FastAPI?
Flask + 多线程的组合在初期运行良好,但我们很快遇到了天花板。多线程的资源开销大,且上下文切换有性能损耗。我们的应用本质上是一个I/O密集型(网络请求、磁盘读写)服务,而不是CPU密集型服务。
在I/O密集的场景下,异步I/O (Asyncio) 是公认的最佳解决方案。
我们决定进行一次彻底的架构升级:**从 Flask (WSGI) 迁移到 FastAPI (ASGI)**。
这次升级带来了巨大的收益:
- 原生异步: 所有的API路由(
async def)现在都运行在同一个事件循环上。当一个请求在等待网络I/O时,服务器会立刻去处理成百上千个其他请求,并发能力呈指数级增长。 - 依赖注入: FastAPI的
Depends系统让我们的verify_api_key认证逻辑变得更加优雅。 - 自动API文档: 启动服务后,我们立即免费获得了
/docs路径下的交互式API文档,这对后续的调试和开发起到了革命性的作用。
3. “异步”与“同步”的桥梁:run_in_threadpool
升级到FastAPI后,我们遇到了一个新问题:api/local.py 和 api/navidrome.py 这两个模块中,包含了大量同步的代码(例如 sqlite3 数据库查询、os.remove 文件操作)。
如果我们在异步的FastAPI路由中直接调用这些同步函数,会像“路障”一样堵塞整个异步事件循环,让异步的优势荡然无存。
我们的解决方案是使用FastAPI提供的“桥接工具”:fastapi.concurrency.run_in_threadpool。
1 | # main.py |
通过这种方式,我们以最小的改动成本,将同步的“老爷车”安全地开上了异步的“高速公路”,而没有造成交通堵塞。
阶段二:核心功能 - 在线API与本地库
我们的服务器有三个数据源,每个都需要一套独特的解决方案。
1. 网易云音乐:eapi 的加密与“金矿”的发现
eapi加密: 我们通过分析,成功地在api/netease.py中复现了eapi的加密算法,使用cryptography库实现了AES加密,让我们能够模拟客户端请求。- “金矿”的发现: 在开发中,我们发现标准API返回的元数据(如作词、作曲)不完整。通过抓包,我们找到了一个未公开的“歌曲百科”接口 (
/api/link/page/parent/relation/construct/info)。这是一个“金矿”,返回了构建App页面所需的所有详细元数据,包括作词、作曲、编曲、制作人、混音、母带、BPM甚至更精确的曲风。我们立刻将其整合到了_get_song_wiki_details函数中,这成为了我们元数据补全功能的核心。
2. QQ音乐:vkey 的破解与并发优化
vkey机制: 我们实现了_fetch_single_url函数,用于模拟客户端获取播放所需的vkey,从而拼接出真实的下载链接。- 并发优化: 在FastAPI架构下,我们将
get_song_urls函数从“串行请求”(一个一个地尝试master,flac,320)升级为asyncio.gather并发请求。现在,脚本会同时向服务器询问所有音质的链接,这使得获取URL的速度从几秒钟缩短到了几百毫秒。
3. 本地音乐库:scanner.py 与 local_api.py
scanner.py(索引器): 我们创建了一个独立的scanner.py脚本,负责遍历您在config.py中指定的MUSIC_DIRECTORY和FLAC_DIRECTORY目录,读取所有音乐文件的元数据,并在music_library.db(SQLite) 数据库中为们建立索引。api/local.py(数据库API): 这个模块封装了所有对music_library.db的同步操作,如search_song(支持音质降级搜索)、get_song_path_by_id等。
阶段三:部署与运维 - Nginx、Samba 与 Systemd
当代码在本地跑通后,将其部署到生产服务器上,我们遇到了全新的、更严峻的挑战。
1. Nginx:从反向代理到安全流媒体服务器
- 基础: 我们使用Nginx作为反向代理,将公网的
musicapi.demo.com域名(443端口)转发到127.0.0.1:5000上运行的uvicorn服务。 - 安全流媒体: 我们很快意识到,直接暴露
/api/local/download/<id>接口是极其危险的。我们转而采用了Nginx的secure_link模块。- Flask/FastAPI (
generate_stream_url): 负责生成一个临时的、带签名的流媒体链接,签名中包含了过期时间戳和我们的私有密钥。 - Nginx (
/secure_media/): 负责验证这个链接的签名和时效性。验证通过后,Nginx会直接将文件发送给用户,完全绕开Python后端,性能极高。
- Flask/FastAPI (
- 流媒体性能优化: 为了解决拖动进度条卡顿的问题,我们在Nginx配置中加入了
slice 1m;指令,开启了“分片处理”,让Nginx能像专业流媒体服务器一样响应Range请求。 - 调试
bind()失败: 我们在尝试将Nginx绑定到Docker的172.17.0.1地址时,遇到了bind() to ... failed (99: Unknown error)的错误。我们通过设置sudo sysctl net.ipv4.ip_nonlocal_bind=1解决了这个问题。
2. 存储方案:从 NFS 到高性能 Samba
- 需求: 我们的存储服务器(Navidrome)和API服务器是两台独立的设备。
- 方案演进:
- NFS: 这是Linux间的首选,但我们的另一台设备不支持。
- Samba (匿名): 我们最初配置了
guest ok = yes的Samba共享,但立刻意识到这在局域网中也极其危险。 - Samba (安全版): 我们迅速切换到了“安全版”方案:创建专用的
music_share_user用户,使用smbpasswd设置密码,并在fstab中通过credentials文件进行安全挂载。
- 测速: 通过
dd命令测试,我们最初的读写速度(4-13 MB/s)虽然够用,但在优化网络连接后,我们将其提升到了 60-80 MB/s 的高性能水平,为流畅播放奠定了坚实的基础。
3. systemd:自动化运维
为了让服务在后台稳定运行并开机自启,我们编写了 systemd 单元文件:
music_api.service: 使用uvicorn启动main:app,并配置为Restart=always。music_sync.service: 定义了如何运行我们的同步脚本,并从.env文件安全地读取密码。music_sync.timer: 一个定时器,用于在每天凌晨3点15分自动触发music_sync.service。
阶段四:AI伴奏与歌单同步
在解决了基础架构问题后,我们开始构建真正让这个项目变得“全能”的高级功能。
1. AI 伴奏工作流
- 工具: 我们使用
Ultimate Vocal Remover GUI(UVR) 在本地电脑上分离伴奏。 - 问题: UVR 生成的文件(如
1_周杰伦 - 龙卷风 Jay [M]_(Instrumental).flac)文件名混乱且没有元数据。 - 我们的解决方案 (一个自动化脚本):
upload_and_tag.sh(本地): 我们在本地创建了这个Shell脚本。首先在本地自动重命名文件,将其清理为周杰伦 - 龙卷风 Jay (Instrumental).flac;然后使用scp将其上传到服务器的INSTRUMENTAL_DIRECTORY;最后通过ssh远程触发服务器上的标记脚本。tag_instrumentals.py(服务器): 这个Python脚本被远程触发后,会扫描INSTRUMENTAL_DIRECTORY。- 智能匹配: 会解析文件名,并在数据库中找到对应的原版歌曲(例如
...[M].flac)。 - 格式统一 (关键): 会检查原版和伴奏的格式(FLAC/MP3),如果不一致,会调用
ffmpeg自动将伴奏转换为与原版相同的格式。 - 完美复制: 因为格式已统一,可以执行一次高保真元数据克隆,复制所有标签,包括专辑封面和
navidrome分组所需的隐藏ID指纹 (musicbrainz_albumid)。 - 添加后缀: 最后,将伴奏文件的“歌曲名”标签修改为
龙卷风 (Instrumental),确保navidrome能将和原版区分开,但又归入同一张专辑。
2. Navidrome 歌单同步
- 建立映射: 我们在
main.py中创建了/api/navidrome/import接口。当用户通过playlist_importer.html导入一个在线歌单时,后端会自动在playlist_mappings表中记录下“在线ID”和“Navidrome ID”的映射关系。 playlist_sync.py(同步器): 这个强大的脚本是整个项目的核心功能之一。当您通过systemd定时器运行时,会:- 登录 Navidrome。
- 遍历数据库中的所有映射。
- 获取两端列表: 异步获取最新的“在线歌单列表”和Navidrome中的“当前歌单列表”。
- 计算差异: 找出需要添加和移除的歌曲。
- 执行同步: 通过Navidrome API执行增删操作。
- 【新】同步顺序: 基于我们抓包发现的
PUT /api/playlist/.../tracks/<id>接口,脚本会反向迭代目标列表,逐一移动歌曲,确保Navidrome中的歌单顺序与在线歌单完全一致。
阶段五:精细化与拓展 - 搜索、推荐
1. 搜索引擎的进化:从“精准匹配”到“模糊检索”
最初的本地搜索逻辑非常原始,依赖于 scanner.py 生成的一个固定格式的 search_key(通常是 歌手 - 歌名)。
为了提供媲美商业App的搜索体验,我们对 api/local.py 进行了一次修改:
- 废弃单一键值查询:不再依赖
search_key的全等匹配。 - 引入 SQL 动态构建:编写了全新的
search_local_music函数。支持多种模式(按歌手、按专辑、按歌名)。 - 智能分词逻辑:最关键的改进在于处理用户输入。
- 空格即“与”逻辑:输入
周杰伦 七里香,后端会自动切分关键词,构建出WHERE (artist LIKE '%周杰伦%' OR ...) AND (title LIKE '%七里香%' OR ...)的查询语句。 - 正则解析:为了支持更复杂的场景(如包含空格的英文名),我们引入了正则表达式来精准提取查询词。
- 空格即“与”逻辑:输入
现在的搜索接口,不仅快,而且“懂”用户的意图。
2. 引入算法推荐:挖掘网易云的“日推”与“风格漫游”
有了庞大的本地库后,如何发现新歌?我们决定“借用”网易云强大的推荐算法。
- 每日推荐:我们分析了 网易云的接口,成功复现了获取“每日推荐歌曲”的功能。
- 风格/场景推荐:这是一个更复杂的交互。
- 获取标签:通过
/eapi/homepage/daily/song/config/get获取所有可用的风格(如“R&B”、“伤感”、“驾驶”)。 - 两步走策略:获取具体歌单时,必须先调用
tag/save接口告诉网易云“我偏好这个风格”,然后再调用song/list获取实际歌单。我们使用asyncio将这两步封装成一个原子的异步操作。
- 获取标签:通过
这一功能的加入,让我们的私有服务器拥有了“源源不断”的新歌来源。