开始之前
在一切开始之前,我希望各位能够对 Project Anni
中的各个部分有一个大致的印象。
Project Anni
包括两个部分:对音乐资源的组织和对音频资源的分发。
前者包含整轨切分、元信息编辑、元数据整理等。Anni
提供了切分整轨、检查音频的工具,并提出了元数据仓库的概念将音频元数据和音频文件分离,便于格式化地整理音乐的元数据。
后者包括一套完整的服务端、客户端应用。客户端用于实际的播放,服务端则用于提供客户端所需的音频、封面等资源。我们曾经设想过将分发的形式以 P2P
的方式呈现,但最终还是选择了传统的 C/S
架构,将 Anni
定位为一个便于 Self host
的项目。Anni
服务端的理想部署环境是 NAS
,但同时也支持基于 Google Drive
的 VPS
部署方式。
目前,前者的实现已较为完善,但在数据格式及工具的易用性上仍有待优化,暂时没有完全稳定;后者则是目前开发的重点,待敲定的包括 Anniv
的协议细节,各服务端和客户端的实现也正在进行中。
讲在前面
Project Anni
的出现是为了填补「不满足我们需求」的空白,而不是为了纯泛用性而生。
和每一个定制项目一样,Anni
并不一定适合每一个人,我们也不奢求所有用户和我们的需求都完全一致。
同时,作为我用 Rust
编写的第一个较大型项目,这对我而言也是一个 Learn to Rust
的过程,因此代码质量一定有所不足,欢迎各位的 Pull Request
。
我只希望,在填坑 Anni
的过程中,不要遗忘了初心。
前言
云音乐
相信大家都有过这样的经历,在各个音乐平台之间挣扎的经历。常常是平台 A 下架了某一首歌,平台 B 依靠独占吸收来自平台 A 的难民。
这种现象很普遍,倒不如说在版权时代这是再正常不过的风景了。没有版权就不能上架,天经地义的事情。平台不过是希望吸引流量又不希望承担流量带来的法律风险罢了,否则一开始又为何要上架呢?
罢了,下载到本地听吧。
本地音乐
本地音乐的来源就非常广泛了。
一是从各大云音乐平台上下载得到的音频。其是否存在加密并不是什么麻烦的事情,因为音乐终究是要播放的,最不济你从操作系统层面把送给音频 API 的每一个采样都截下来不就拿到 wav
了吗(笑)
二是各大公网音乐包,比如 [Nemuri]
的合集。这种合集的特点是音乐全,格式整齐,通常以统一的格式进行分享(如 FLAC
、MP3
等)。
有了音频文件,再配上音乐播放器就可以和云音乐一样听歌了。本地音乐的一大缺点在于更新,没有了云音乐运营,一切的一切都要靠你自己了。
当然了,这并不是本地音乐唯一的问题。本地音乐最大的问题恰恰在于本地。且不论本地音乐增多后多端的存储成本,单单是音乐的同步就够你喝一壶的。就以基本的 PC
、手机、平板三端同步为例,你需要在这三台设备之间反复横跳——一旦某一台设备没有同步,而你恰巧想要听某一张新发售的专辑——噩梦就降临了。
于是,我们发现,云音乐恰巧是为了解决这些痛点而存在的。那我们要回到云音乐平台吗?
自建音乐平台
既希望得到本地音乐的便利,又想要云音乐的便利,这就是自建音乐平台的意义所在了。
最常见的自建音乐平台应该就是 Airsonic
了,这是一个用 Java
写成,基于 Subsonic
的最后一个开源版本 fork
而出的项目,而 Subsonic
则是著名的自建音乐平台。
Airsonic
的存在已经能够满足很多人的需求了,但它还是无法完全满足我当前的需求,原因有以下几点:
Airsonic
的编写语言是Java
,其需要的资源对小型VPS
而言还是太多了。以我目前运行的Docker
为例,没有任何人使用的情况下,基本内存占用维持在800MiB
左右。- 自建音乐平台需要服务器有较大的硬盘空间,而这一点面对只会增多不会减少的音乐资源难以永久实现。目前我整理的音乐资源有
185GiB
,这对于我部署Airsonic
的20G
小鸡肯定是不现实的。 - 为了应对硬盘问题,我使用了
rclone
挂载。但在音乐更新后,Airsonic
需要花费大量的时间(小时级别)重新扫描整个音频目录,读取每一个音频文件的元数据。这么长的时间差完全不能满足「马上就听」的需求。 Airsonic
支持部分更新,根据目录和文件的lastModifiedTime
决定是否扫描。但Google Drive
对于这部分的实现并不遵循一般文件系统的行为,对内层文件的更新并不会导致外层目录的修改时间变化,宣告了Airsonic
的这个特性完全无法使用。Airsonic
诸如音乐上传、下载、last.fm
的功能是我不需要的,而且没有办法完全关闭。使用这些功能可能会导致未定义行为(如上传到rclone
挂载的目录可能会打乱原本的目录结构)。尽管我可以不使用这些功能,但我不能要求和我一起整理资源,共用平台的朋友永远都不使用这些功能。- 更新缓慢,
Bug
丛生。尽管有另一个fork
但问题还是很多,且对我而言维护起来过于困难。(更新:现在只有这个fork
在积极维护,原仓库已经Archive
了)
当然了,多多少少还有一些没有列出来的问题。由于这些问题的存在,最终促使我决定在另一个意义上自建音乐平台:自己造轮子。
我们需要什么?
在 Project Anni
发起半年后,我再次回头来看这个问题:我们需要什么?
从听音乐的角度来看,最基础的需求是我们需要听音乐,在此之上的是我们需要听高音质的音乐。更进一步,我们希望在不同的时间根据不同的需求听不同种类的音乐。
从信息的角度来看,最基础的是我们需要知道音乐的标题,然后是艺术家、封面等其他信息。更进一步,我们希望在专辑、艺术家等信息之间快速跳转。最重要的是,我们不希望信息消失(点名批评网易云音乐)。
理想的 Anni 部署结构
Anni
在设计之初是针对资源所有者的。理想的部署模型是资源所有者通过 Annil
共享自己已有的资源,用户通过添加所有者的 Annil
地址以访问资源所有者共享的资源。资源所有者通过凭据限制可以访问资源者的身份、人数等。
但随着资源整理的深入,Annil
对个人呈中心化的趋势已日趋明显。Anni
逐渐成为我个人从各资源平台获取音乐资源、整理后收听的唯一途径。在这种变化下,各设备只与一个 Annil
通信并获取音频已成为目前我个人使用中的事实架构。
与辅种的矛盾?
Anni
的设计目标是没有考虑到辅种的,或者换句话说,Anni
的目标是将通过 Anni
整理后的音频作为做种的目标。Anni
整理后的音频资源包含相对统一的音频标签和内嵌的封面,并以统一的文件名和格式呈现。Anni
本身也提供了对标签等信息进行检查的能力,确保结果音频在元数据这一层面的质量相对较高。
这样的设计考量意味着如果你希望在使用 Anni
的同时保种,那你就需要在硬盘上存放两份音频数据。不过一般音频相比其他资源而言占用空间还是较小的,我整理到现在近 1000
张单曲/专辑的空间占用也只有 300GiB
不到(不含 Hi-res
),个人认为空间问题还是相对较小的。
当然了,如果你整理的目标是 TLMC
这样的巨型合集,那就是另一个故事了(93661
个项目,总计 1.7 TiB
,笑)。
与 Airsonic
对比
和 Airsonic
相比,Anni
的优势如下:
- 扫描速度快。对于以
Google Drive
形式存储在远端的370G
音频资源,Annil
目前的扫描只需要20
秒左右就可以完成。相比之下,在资源只有不到100G
的时候,Airsonic
就需要扫描两小时以上了。这是因为Anni
的扫描只需要扫描目录结构,并以专辑为单位定位其所在的目录,并不需要实际读取音频。如果将所有专辑都存放于单一目录下,或使用严格目录格式的情况下,扫描的时间理论上可以基本无视。 - 元数据更新方便。由于元数据和音频文件脱钩,因此修改元数据只需要对元数据仓库中的文本进行修改即可。将错误修正写回音频文件的过程并不是必须的(尽管一般个人因为强迫症会选择写回)。
- 歌单等附加性功能(
Anniv
)与音频分发(Annil
)脱钩。这意味着如果我们不需要多余的功能,只想专心听歌,完全可以只使用Annil
。而分拆出来的Anniv
则可以放手实现各种附加功能。并且数据还可以跨Annil
存在。 - 资源占用小。个人实际使用下
Annil
的待机内存占用在15MiB
以内,客户端访问时(在不转码的情况下,转码调用的是ffmpeg
)也不超过20MiB
。考虑到Annil
目前的部署场景基本是Self Host
,这样的资源消耗对我个人而言可以说是完美的。
而目前的劣势也很明显:
- 需要手动整理。这是
Anni
存在的基石,Anni
只会提供更便于整理资源的工具,一定不会跳过资源整理这一步。 - 对收藏、歌单等功能的支持还没有开始。
Anniv
的协议还没有完全敲定,也没有可用的前端、后端、客户端。目前的客户端是实现了Airsonic
协议后借用的DSub
等Airsonic
客户端,只是还算能用的状态。
Anni 是如何工作的?
从组成上来说,Anni
分为三个部分:
- 音频文件相关:音频元信息处理、整轨切分、元数据导入/导出
- 音乐仓库相关:仓库结构管理、数据整理
- 服务端/客户端相关
音频文件相关
整轨切分
ArchLinux Wiki
上专门有一页专门介绍如何切分 CUE
,其中用到的就是 shntools
中的 shnsplit
。配合上 cuetools
的 cuetag.sh
,可以相对方便地实现整轨的切分和音频元数据的导入。
笔者持续了这样的切分方式一段时间(如下),但最终还是放弃了。
NAME="${$(ls *.cue)%.cue}" && shnsplit -f "$NAME.cue" -o "flac flac --picture cover.jpg -o %f -" "$NAME.wav" -t "%n. %t"
放弃的原因很大一部分其实源于这个脚本没写好,但实际我也有点厌倦了这种有点复杂的切分方式。虽然 flacon
或许也是个不错的选择,但其默认的设置并不讨喜,首先是会强制把封面图片转小并强制替换,其次是输出标签的 key
都是小写的。
此外,shntool
对 CUE
读取的实现也存在一些问题:其无法识别 UTF-8 With BOM
的文件。隔壁的方案是使用 tail
,于是脚本就变得更加复杂了(笑)
从 Anni
对这方面的支持程度可以看出这个项目成长的轨迹。早期版本实现的是读取 CUE
并输出可工作的 bash
脚本。从本质上来说,这样的工作方式和上面直接用脚本的方式没什么不同,但区别在于:
CUE
的解析更可控了,可以进行一些基于内容的检查- 实现的
CUE
部分还可以用于项目的其他部分
而目前的版本则是基本实现了 shnsplit
的对应功能,可以根据 CUE
切分并输出 wav
、flac
或 ape
格式的音频。Anni
实现了 wav
的解析与切分;而其他格式则是调用对应的解码器解码为 wav
后再进行切分,与 shnsplit
一致。
目前使用的 CUE
解析库为 ProjectAnni/cue_sheet,fork
自 leoschwarz/cue_sheet
。Anni
在这个实现的基础上修复了 REM
解析的问题。
音频元数据处理
音频元数据的可靠来源为发行商的官方网站、歌手的个人主页、企划/组合的官网和 BK
;较可靠来源为 iTunes
上的专辑信息,VGMDB
等数据库收集的非一手资料;次可靠来源为 CUE
中附带的元信息等。
参差不齐的数据需要经过一定的整理后才能写入音频文件,作为自带的元数据存储。由于音频元数据的变动需要修改整个音频文件,更新较为麻烦,因此我们希望尽量减少对音频本身的修改,力求一次到位,将正确的元数据写入后就不再变动。
元数据导入/导出
在整理「THE IDOLM@STER
」的音乐资源时,我拿到的资源是这样的:
- 所有资源均为
FLAC
格式,以罗马音命名 - 有内嵌元数据,但同样有大量罗马音填充
理想的整理方式是这样的:
- 从一个目录的
FLAC
中将专辑的元数据导出到文本 - 和可信源进行对比
- 将整理后的元数据重新导入
FLAC
中
这也是 Anni
目前的分轨整理方式。Anni
的官方仓库位于 ProjectAnni/repo
,目前存放了我制定元数据仓库格式以来整理的所有的专辑信息。
音频仓库相关
音频仓库(Audio Library
)是用于存放音频文件的系统,对目录组织的要求较为松散,但对专辑目录及音轨的命名有着较严格的要求。
元数据仓库(Metadata Repository
)是用于存放音频元数据的仓库,格式要求严格。
在二者的结合下,我们便可以通过 (AlbumID, DiscID, TrackID)
三元组的形式索引 Anni
系统中的所有音频文件。
术语表
此处列出该文档中使用的所有术语。文档编写者在书写时应以此处的术语为准。
主要术语
Anni(狭义)
为整理音频而写的一套命令行工具
Anni(广义)
Project Anni
。
品番(Catalog
)
在未冲突的情况下可用于唯一表示专辑的字符,但可能存在冲突情况。
元数据(Metadata
)
描述音频信息的数据,一般会内嵌在音频文件中,包括标题、专辑、艺术家等。
元数据仓库(Metadata Repository
)
Anni
中统一管理元数据的仓库。
音频来源(Audio Provider
)
Anni
中音频和封面文件的实际来源/存放方式。可以是本地、Google Drive
等。
音频仓库(Audio Library
)、Annil
(广义)
以 Annil
协议分发音频文件及封面的服务端。
Annil
(狭义)
Project Anni
定义的一种音频服务端协议。
Anni
音频约定(Anni Audio Convention
)
Project Anni
定义的一套音频文件管理方式约定,包括必须和可选部分。
约定目录格式
Anni
音频约定定义的音频文件、目录、封面组织形式。
严格目录格式
在 Anni
音频约定的基础上,取消目录嵌套,将专辑目录名修改为对应专辑 ID,音轨文件名修改为对应 Track Id
,更便于分发和共同整理的目录格式。
Annix
(WIP)使用 flutter
编写的客户端。x
代表 Cross Platform
中的 cross
。
Anniw
(WIP)网页端单页应用,w
代表的是 web
。
Anniv
音频管理后端,负责歌单、歌词等,与音频仓库解耦。
开发术语
这部分术语一般用户不必关心,仅开发者需要阅读。
anni-flac
提供 flac
文件解析相关功能的库。
anni-repo
提供元数据仓库操作功能的库。
anni-provider
实际实现音频后端的库。
anni-fetch
模拟 git fetch
工作的库,在该库基础上可以实现元数据仓库更新相关功能。
anni-vgmdb
获取 VGMdb
专辑信息的客户端。
anni-common
跨 Anni
项目的公用库。
anni-clap-handler
、anni-clap-handler-derive
处理 Clap 子命令调用的 handler 库。
鸣谢
成员/贡献者
TODO
开源项目
本项目的出现离不开前人项目们的引导和支持,因此在此特别列出。
项目名 | 链接 |
---|---|
cuetools | https://github.com/svend/cuetools |
shntool | http://shnutils.freeshell.org/shntool/ |
Airsonic | https://github.com/airsonic/airsonic |
flac | https://github.com/xiph/flac |
Anni 音频约定
Anni
音频约定是 Project Anni
为了规整格式、简化实现而对音频文件/专辑增加的一系列规定。
Anni
保证满足 Anni
音频约定的音频文件/专辑在使用过程中不会出现未定义行为,并以约定为基础进行开发。
约定中的部分参数可以通过配置文件的形式修改,约定外的需求可以通过 Issue
的方式申请加入约定。
前言
Anni
音频约定实质上是基于对 Project Anni
的理解而生的,某种意义上也可以称作是 Yesterday17's Audio Convention
。如对采样率和位深度的限制,完全是基于分发的角度考量的。你完全可以用 Anni
收藏 Hi-Res
的音乐,但在使用时就需要承受其体积带来的问题。
Anni
音频约定不是强制的,这只是我个人对于音乐资源整理的一己之见。Anni
作为贯彻我这种独断而开发出的工具,可以较为方便地将音频组织成符合该约定的格式。
简称
后文中以 「Anni
约定」代之 Anni
音频约定。
音频格式
存储格式
Anni
存储格式的定义是在库的音频格式,即整理完的音频格式。
Anni
约定指定 FLAC
为唯一存储格式。
分发格式
在 Anni
作为云音乐平台工作时,存在向用户分发音频的行为。这个过程中涉及到的音频格式称为分发格式。
Anni
约定以下格式可作为分发格式:
FLAC
AAC
OPUS
采样率
建议取 44100 Hz
,即 CD
的采样率。
最高为 48000 Hz
,不建议继续升高,如 96000 Hz
。
作为非专业用户,在此基础上的采样率提升只是浪费磁盘空间和带宽。
位深度
位深度取 16
即可,即采样为 16 位带符号整数。
对应 ffmpeg
中的 AV_SAMPLE_FMT_S16
。
音频标签
由于 FLAC
为唯一存储格式,因此音频标签相关的约定也以 FLAC
为基础描述。
基本概念
FLAC
的音频标签可以简单地理解为一个字符串数组,由 KEY=VALUE
格式的字符串组成。
在此基础上,规定了一些常用的 KEY
作为某一项属性的名称。如 TITLE
即为标题。
约定标签
Anni
约定指定了一部分必须标签和可选标签。除此之外的标签均为不建议标签,Anni
不会对其作任何处理。
必须标签
KEY | 简介 |
---|---|
TITLE | 标题 |
ARTIST | 艺术家 |
ALBUM | 专辑 |
DATE | 专辑发售日期 |
TRACKNUMBER | 音轨在唱片中的编号 |
TRACKTOTAL | 唱片中的总音轨数 |
DISCNUMBER | 唱片在专辑中的编号 |
DISCTOTAL | 专辑中的总唱片数 |
可选标签
KEY | 简介 |
---|---|
ALBUMARTIST | 专辑艺术家 |
COMPOSER | 作曲 |
ARRANGER | 编曲 |
LYRICIST | 作词 |
Vendor String
Vendor String
是存在于 FLAC
的 Vorbis Comment
块首部的一个字符串。
目前 Anni
约定并未对其有所约束。后续可能增加,但大概率不会有过多要求。此处仅留作 Placeholder
。
艺术家
艺术家必须保留原名,不可填写译名或罗马音。
艺术家姓名之间不带空格,首尾不带空格。
多艺术家
多艺术家之间以中文顿号(、
)分隔。
如 [140827][GNCA-1418] ごちうさブレンド
中 02. Rabbit Hole
的艺术家就可以表示为 ココア、リゼ
。
艺术家别名
以中文括号用表示艺术家别名。
如上文 02. Rabbit Hole
的艺术家还可以详细表示为 ココア(佐倉綾音)、リゼ(種田梨沙)
。
艺术家别名嵌套
艺术家别名和多艺术家也可以嵌套,形成以中文括号和中文顿号表示的复杂艺术家关系。
如 TrySail
组合曲的艺术家可以表示为:TrySail(雨宮天、麻倉もも、夏川椎菜)
。
别名也支持多级嵌套,如 [201028][GNCA-0620] 天空カフェテリア
中 01. 天空カフェテリア
的艺术家可以表示为:
Petit Rabbit's(ココア(佐倉綾音)、チノ(水瀬いのり)、リゼ(種田梨沙)、千夜(佐藤聡美)、シャロ(内田真礼))
艺术家名转义
由于艺术家名称中可能出现上文规定的特殊字符(括号、顿号),因此在特定情形下需要对这类字符进行转义。
转义字符为 \
,在 \
之后的任何字符都会被认为是艺术家名称的一部分,不会被当作控制字符。
额外地,当艺术家名称中出现顿号(、
)时,可以通过双写顿号进行转义。但如果希望将顿号用作艺术家名称的第一个字符,还是需要通过 \
进行转义。
FLAC 中的多艺术家
由于 FLAC
使用的元数据格式定义了可以通过多个 ARTIST
字段表示多艺术家,因此第一层的多个艺术家可以通过这种形式存储,省略这一层的转义。
艺术家别名及嵌套的意义
虽然艺术家别名的直接观感并不是很好(尤其是在多级嵌套之后),但较为规整的格式使得播放器可以对这样的格式进行更为复杂的支持。播放器完全可以通过别名支持基于艺术家的复杂歌曲检索,组合名也就可以方便地映射到个人。
而如果播放器选择不支持,艺术家别名也可以简单地消除,只留下艺术家的原名。
即使播放器对这一项完全不作任何修正,得到的格式也比交杂着顿号(、
)、斜杠(/
)、与号(&
)等分隔符的格式要直观许多。
专辑名
一般填写官网的对应名称,不填写 /
之后的作者信息。
当多个版本歌曲内容一致时,不保留盘片的发行版本信息。如初回限定盘和通常盘内容相同的情况。
当多个版本歌曲内容不同时,保留发行信息。如 22/7
的初回限定 Type-A
、Type-B
和通常盘的最后一曲不同的情况。
对单张专辑的多张唱片,各曲的专辑名保持一致。
日期
日期需填写发行的准确年月日,如 2021-01-25
。
对于只支持年份的播放器,前四个数字之后的文本会被忽略;对于支持的播放器,则可以按详细的日期进行排序。
对重复项、空项的说明
由于 FLAC
标签系统的特性,某一个 KEY
可能对应多个 VALUE
,而 VALUE
则可能为空。
Anni
约定严格禁止重复项和空项。对可能存在的重复项和空项,Anni
约定处理逻辑如下。
重复项
Anni
对重复的项会取其有内容的最后一项。以下面的 COMMENT
为例:
KEY=VALUE
KEY=
最终得到的是 KEY=VALUE
,而:
KEY
KEY=TEST
KEY=
KEY=OVERRIDE
则会得到 KEY=OVERRIDE
。
空项
Anni
对空项的取值为空,不存在初始值。
如果在 Anni
处理过程中遇到必须项为空,Anni
会产生一个错误。
对间隔点(・)的说明
在音频标签中,统一使用 ・
(U+30fb
)作为间隔点。
如果遇到其他的间隔点,请修正为上述符号。在编码转换时需额外留意。
参考
对斜线(/)的说明
由于斜线被各大操作系统用作目录之间的分隔符,因此无法用作目录或文件的名称。
当为专辑命名时,必须对专辑目录名作此修正。可以根据语义替换成分数号(⁄
)或除号(∕
),否则替换成全角斜线(/
)。
当为专辑内歌曲命名时,写入标签的歌曲名称不作任何修正,文件名中以全角斜线(/
)替换斜线(/
)。
Anni
会自动修正歌曲的文件名中的斜线。
对波浪线(~)的说明
在音频标题中,统一使用 Unicode 全角波浪线 ~
(U+FF5E
)作为波浪线。
当遇到 Wave Dash 〜
(U+301C
)时,请修正为上述符号。
参考
专辑封面
专辑对应的封面图片,只接受 image/jpg
。
专辑目录下须存在 cover.jpg
以表示封面。音乐文件须内嵌封面。
清晰度
封面需要尽量清晰,建议在 800x800
以上。
封面来源
可以在发行商官网、iTunes、Hi-Res 平台、VGMdb 等网站寻找。
文件名
音频文件
音乐资源以 {tracknumber:02}. {title}
命名,扩展名为 flac
。
封面文件
封面文件名必须为 cover.jpg
,文件名全小写。
品番
品番(Catalog
) 是存在于(几乎)每一张专辑上的编号。
Anni
约定其对于各专辑近似唯一,在不严格的情况下可以作为专辑的主键。
连续表示
当多个 Catalog
连号时,使用 ~
表示连贯,取最低位连续。如表示 VVCL-1466
和 VVCL-1467
两张专辑须使用 VVCL-1466~7
,而非 VVCL-1466~67
。
私有品番域
对于没有品番的专辑,Anni
约定以 @
开头的品番为私有品番域。
手动分配
当 CD
属于 BD
的一部分,且 BD
给 CD
预留了 Catalog
,则按 CD
对应的 Catalog
填写。
若单张 CD
属于 BD
的一部分,但 BD
没有为 CD
预留 Catalog
,则按 BD
对应的 Catalog
填写。
若 CD
存在多张唱片但只分配了一个 Catalog
,如原 CD
定义了品番为 TEST-001
,存在两张 Disc
。则此时两张 Disc
的品番分别为:
@TEST-001-01
@TEST-001-02
此处的 @
使用虚拟品番域,表示该品番并非真实品番;后缀的 -01
和 -02
为 DiscId
,补足两位数字。
约定目录结构
除存储专辑的专辑目录以外,Anni
的目录并没有太多的约束。不过我们建议通过这样的层级组织你的音乐仓库:
- 顶级目录
- 分类目录(可选)
- 专辑目录
该目录结构格式称为约定目录结构。
目录类型
目录分为如下类型:
标识符(TYPE ) | 类型 | 简介 |
---|---|---|
A | 个人 | 歌手个人专辑 |
AAAA | 企划 | 企划类专辑 |
Anime | 番剧 | 番剧相关专辑 |
Game | 游戏 | 游戏相关专辑 |
顶级目录
顶级目录负责将音乐资源按类型和名称分隔为各个大类。
目录名
顶级目录的目录名格式如下:
[{type}] {name}
其中 type
取目录类型中的标识符,name
则为对应名称。
示例
以下均为合法的顶级目录:
[A] Aimer
[A] 水瀬いのり
[A] 雨宮天
[A] 麻倉もも
[A] 夏川椎菜
[A] 中島由貴
[A] ReoNa
[AAAA] THE IDOLM@STER
[AAAA] THE IDOLM@STER SHINY COLORS
[AAAA] TrySail
[Anime] ご注文はうさぎですか?
[Anime] 神様になった日
[Anime] 魔王城でおやすみ
[Anime] 冴えない彼女の育てかた
[Game] ATRI -My Dear Moments-
[Game] ef - a fairy tale of the two.
[Game] ライザのアトリエ
[Game] Rewrite
[Game] サクラノ詩-櫻の森の上を舞う-
[Game] 素晴らしき日々
[Game] Summer Pockets
专辑目录
专辑目录负责存储单张专辑的所有音乐。专辑封面必须在每个子目录下存在。
目录名
音乐资源目录以 [{date}][{catalog}] {album} [{disc_num} Discs]
命名,其中 {date}
的格式有两种:省略 20xx
年中 20
的 6
位数字日期;和以 -
隔开的 8
位数字日期。
6
位数字日期以 CD
出现的 1982
年作为分隔点,82-99
表示 19xx
年,00-81
表示 20xx
年。
当只有一张碟片时,最后的 [{disc_num} Discs]
可以省略。
DATE6 = /\d{6}/
DATE-8 = /\d{4}-\d{2}-\d{2}/
唱片目录
当存在多张唱片时,专辑目录内需要另根据盘号分隔唱片目录。
唱片目录的命名格式为:[{catalog}] {album} [Disc {disc_num}]
,其中 catalog
为每张盘对应的 Catalog
。
如唱片的 Catalog
在原盘已有分配,则母目录的 catalog
中需使用 ~
号表示各唱片的连贯 Catalog
。
示例
以下均为合法专辑目录:
[180704][VVCL-1261] ELZA
[180929][VVCL-1285] SWEET HURT
[190206][VVCL-1388] forget-me-not
[190626][VVCL-1466~7] Prologue
[190828][VVCL-1485] Null
[200722][VVCL-1680] ANIMA
[201007][VVCL-1744] unknown
分类目录
分类目录位于顶级目录之下,专辑目录之上,负责在二者之间增加一层嵌套以方便整理。
如果使用了分类目录,建议将所有专辑都放置在各个分类目录中,不建议直接位于顶级目录下。
目录名
分类目录的目录名没有任何格式限制。
应用场景
如 THE IDOLM@STER SHINY COLORS
的 CD
存在多个系列:
BRILLI@NT WING
FR@GMENT WING
GR@DATE WING
L@YERED WING
COLORFUL FE@THERS
此时就可以通过分类目录,减少顶级目录下的目录数量,方便整理。
严格目录结构
约定目录结构结构较为松散,对分发而言较为方便。但这也给 Annil
的有效利用,以及整理完成后的同步带来了一定难度。由此,Anni
提出了一种较为严格,便于结构化处理的目录格式。
描述
专辑单元
每张专辑均以 album_id
命名,并且在专辑目录中须存在 cover.jpg
表示该专辑的封面。
专辑中的各张碟片均以 disc_id
命名目录,且不允许携带前缀 0
。碟片目录中须存在 cover.jpg
表示该碟片的封面。
碟片中的各音轨以 {track_id}.{ext}
命名,ext
表示音频的扩展名。track_id
同样不允许携带前缀 0
。
Hash 分散
为了减少单一目录下子目录的数量,严格目录结构允许在 album_id
的基础上通过多级子目录对目录进行分散。子目录嵌套层数由参数 layer
控制,取值区间为 0-4
,默认为 2
。
每一级目录名均取 album_id
字符串的两位(一个字节),且不携带前缀 0
。
以默认情况下的 5a0c666f-fe66-4c01-8cde-a3b45118f25f
为例,其第一级目录名为 5a
,第二级为 c
。
目录结构示例
.
├── 5a
│ └── c
│ └── 5a0c666f-fe66-4c01-8cde-a3b45118f25f
│ ├── 1
│ │ ├── 1.flac
│ │ ├── 2.flac
│ │ ├── 3.flac
│ │ └── cover.jpg
│ └── cover.jpg
└── 67
└── 53
└── 675377ef-62e0-4192-a465-c1d025871ec0
├── 1
│ ├── 1.flac
│ ├── 2.flac
│ ├── 3.flac
│ └── cover.jpg
└── cover.jpg
Anni 元数据仓库
⚠️️目前的元数据仓库标准仍处于
Draft
状态,尚未完全定稿。欢迎随时提出意见或建议。
⚠️️目前的元数据仓库标准版本(
edition
)为1.0+alpha.1.5.1
。
尽管 Anni
约定对音频文件内嵌的标签有了严格的约束,但从实现的角度来看,如果所有的元数据都从音乐文件中获取,那么势必会导致如下问题:
- 音乐库存与音乐资源强关联。在得到音乐资源并写入音频文件的标签之前,我们无法更新音乐仓库的信息,即「无法提前准备内容」。
- 数据的更新伴随着对文件的扫描。对于存储于网络和慢速硬盘上的资源,扫描需要消耗大量的时间,而这是我们希望避免的。
基于上述原因,我们将音频的元数据独立出来,以静态的形式存放。称为「仓库」是因为元数据仓库建议通过 Git
仓库的形式维护。
目录结构
元数据仓库的基本结构如下所示:
├── album
│ ├── KSLA-0178.toml
│ └── @META-DUPLICATED
│ ├── @META-DUPLICATED.0.toml
│ └── @META-DUPLICATED.1.toml
└── tag
│ └── [Anime] 神様になった日.toml
├── repo.toml
专辑存放的目录由 repo.toml 指定。
专辑信息
对专辑信息的描述存放于元数据仓库下指定的专辑存放目录,以专辑的品番作文件名。
专辑信息通过 toml
格式描述。
艺术家
专辑信息中有 artist
和 artists
字段用于填写艺术家信息。其中 artist
字段较为简单,而 artists
字段则可以表示更加精确的艺术家信息。
artist
字段
专辑、唱片和音轨都拥有 artist
字段,用于表示主要的艺术家信息。主艺术家的选取规则如下:
- 当该曲目的类型为
Normal
时,主艺术家为Vocal
。特别地,对于VOCALOID
或其他歌声合成软件制作的歌曲,Vocal
由P主和歌姬共同构成。 - 当该曲目的类型为
Instrumental
时,主艺术家为对应非Instrumental
版本的Vocal
或Composer
。特别地,当该曲目对应非Instrumental
曲目为某曲目的Rearrange
时,主艺术家为对应非Instrumental
版本的Vocal
或该曲目的Arranger
。 - 当该曲目的类型为
Absolute
时,主艺术家为Composer
。特别地,当该曲目为某曲目的Remix/Rearrange
时,主艺术家为Arranger
。
示例如下:
- 《Track A》为
Artist A
个人单曲中的某一音轨,曲目类型为Normal
。此时主艺术家为Vocal
,即Artist A
。 - 《Track B》为
Artist A
个人单曲中的某一音轨,曲目类型为Instrumental
。此时主艺术家可以为Vocal
(即Artist A
),或作曲(Composer
)。 - 《Track C》为
Artist A
个人单曲中的某一音轨,曲目类型为Instrumental
,且该音轨为 《Track B》 的Rearrange
。此时主艺术家可以为Vocal
(即Artist A
),或《Track C》的Arranger
。 - 《Track D》为
Artist A
个人单曲中的某一音轨,曲目类型为Absolute
。此时主艺术家为该曲目的作曲(Composer
)。 - 《Track E》为
Artist A
个人单曲中的某一音轨,曲目类型为Absolute
,且该音轨为 《Track D》 的Rearrange
。此时主艺术家为《Track E》的Arranger
。
artists
字段
artists
字段用于描述更加精确的艺术家信息。该字段的继承方式与 artist
相同,但其中字段不继承。如下所示:
# ...
[[discs.tracks]]
title = "夏凪ぎ(Episode 9 Ver.)"
artists.vocal = "やなぎなぎ"
artists.composer = "麻枝准"
artists.lyricist = "麻枝准"
artists.arranger = "MANYO"
artists.piano = "kidlit"
artists.violin = "須原杏、沖増菜摘"
artists.viola = "梶谷裕子"
artists.cello = "渡邉雅弦"
artists.irish-harp = "梅田千晶"
使用 TypeScript
描述的类型表示如下,其中 []
中的内容表示存在时向 FLAC
文件写入的 Tag
名:
type Artists = ExtendedArtists & Record<string, string>;
interface ExtendedArtists {
// 歌手
vocal?: string;
// 作曲 [COMPOSER]
composer?: string;
// 作词 [LYRICIST]
lyricist?: string;
// 编曲 [ARRANGER]
arranger?: string;
}
标签
专辑、光盘和音轨上都可以存在标签。标签可以用于描述专辑、光盘和音轨的性质,语义为「属于」。各个标签相互独立,不存在继承关系。如:
(音轨)夏凪ぎ 属于 OP
(音轨)宝物になった日 属于 ED
(专辑)夏凪ぎ/宝物になった日 属于 神様になった日
当用于检索时,标签使用「向上合并」的语义。即:专辑的合并标签定义为专辑内所有专辑标签、光盘标签和音轨标签的并集。此时标签的语义为「包含」。如:
(专辑)夏凪ぎ/宝物になった日 包含 神様になった日、OP、ED
对于标签的使用方式,作如下约定:
- 对单曲碟片中的曲目,同一曲目在其各版本收录单曲中标签应保持一致。
- 对专辑碟片中的曲目,如同一曲目在此前发售的单曲碟片中已经存在,则专辑中不需要包括该标签。
音乐类型
对明显有歌、曲的音轨,定义其类型为 Normal
;对于此类歌曲的伴奏版本,定义类型为 Instrumental
;对于不为上述情况的无人声音轨,或 Vocal & Chorus
音轨,定义其类型为 Absoulte
。
对于以角色身份进行故事演绎的音轨,定义其类型为 Drama
。
对于以真人身份进行的,明确以广播形式发行的音轨或广播节目,定义其类型为 Radio
。
对于以真人身份进行的,但不属于广播的音轨(如 Bonus Track 中的对话),或以角色身份演绎的,仅包含单句或多句语音的短音轨,定义其类型为 Vocal
。
示例
######################################################
# 专辑信息
[album]
#
# 专辑 ID [必填项]
#
# 表示为随机生成的 UUID
album_id = "572c5c19-0080-404b-9d8b-2eb864aea75d"
#
# 专辑名称 [必填项][ALBUM]
#
# 专辑的标题,盘片类型信息需要填写到专辑类型下。
title = "夏凪ぎ/宝物になった日"
#
# 专辑类型 [可选项][ALBUM]
#
# 当该项非空时,写入 ALBUM 中的专辑名为 "{title}【{edition}】"。
edition = ""
#
# 专辑品番 [必填项]
#
# 当存在多张时用 ~ 隔开,如:
# catalog = "ALBUM-01~50"
catalog = "KSLA-0178"
#
# 专辑艺术家 [必填项]
#
# 使用「Anni 艺术家别名嵌套」格式表示具体的艺术家。
artist = "やなぎなぎ"
#
# 专辑的发行日期 [必填项][DATE]
#
# 日期有两种表示方法:
# date = 2021-06-22
# date = "2021-06"
#
# 日期表达形式更加精确,当没有精确日期时则可以使用字符串表达模糊的年月。
# 在字符串表达格式通过 `-` 分隔年月日,月和日都可以省略。
# 尽量精确到日,如果无法确定则精确到月或年。
date = 2020-12-16
#
# 专辑的标签 [可选项]
#
# 会和光盘/音轨的标签取并集,共同描述专辑中所含内容的分类。
tags = [
# 使用标签的 type 指定重名 Tag 的具体指代对象
# 标签名称前后的空格会被自动剔除
"group:4U",
# 当没有指定标签类型时,如果仓库内存在类型不同的同名标签,则会抛出错误
# 当该标签名称唯一时,则不会报错
"神様になった日",
]
#
# 音乐类型 [必填项]
#
# 可选项如下所示:
# normal(默认):有人声的歌曲。
# instrumental:无人声的伴奏。
# absolute:纯音乐。
# drama:以人声为主的单元剧。
# radio:以人声为主的广播节目。
# vocal:纯人声,如音乐现场的 MC 等。
type = "normal"
######################################################
# 光盘信息
[[discs]]
# 光盘名称 [可选项]
title = "夏凪ぎ/宝物になった日"
# 光盘艺术家 [可选项]
artist = "やなぎなぎ"
#
# 光盘品番 [必填项]
#
# 只允许表示一张光盘,不得出现 ~ 连接符。
catalog = "KSLA-0178"
#
# 光盘标签 [可选项]
#
# 会和专辑的标签取并集,共同描述专辑中所含内容的分类。
tags = []
#
# 音乐类型 [可选项]
#
# 用于覆盖专辑的音乐类型。
type = "normal"
######################################################
# 音轨信息
[[discs.tracks]]
# 音轨标题 [必填项][TITLE]
title = "夏凪ぎ"
#
# 音轨标签 [可选项]
#
# 会和专辑的标签取并集,共同描述专辑中所含内容的分类。
tags = ["OP"]
[[discs.tracks]]
title = "宝物になった日"
tags = ["ED"]
[[discs.tracks]]
title = "夏凪ぎ(Episode 9 Ver.)"
#
# 音轨详细艺术家 [可选项]
#
artists.vocal = "やなぎなぎ"
artists.composer = "麻枝准"
artists.lyricist = "麻枝准"
artists.arranger = "MANYO"
artists.piano = "kidlit"
artists.violin = "須原杏、沖増菜摘"
artists.viola = "梶谷裕子"
artists.cello = "渡邉雅弦"
artists.irish-harp = "梅田千晶"
[[discs.tracks]]
title = "宝物になった日(Episode 5 Ver.)"
[[discs.tracks]]
title = "夏凪ぎ(Instrumental)"
#
# 艺术家 [可选项][ARTIST]
#
# 省略时,默认以光盘艺术家 -> 专辑艺术家的顺序寻找存在项。
artist = "麻枝准"
#
# 音乐类型 [可选项]
#
# 用于覆盖唱片的音乐类型,最终形成每个音轨的音乐类型。
type = "instrumental"
[[discs.tracks]]
title = "宝物になった日(Instrumental)"
artist = "麻枝准"
type = "instrumental"
品番冲突
当出现品番冲突时,原本以单一品番命名的文件需要升级为以品番命名的目录。
目录中的专辑信息以 {catalog}.{index}.toml
命名,索引从 0 开始。
如:专辑 TEST-001
发生品番冲突,则目录结构如下:
├── album
│ └── TEST-001
│ ├── TEST-001.0.toml
│ └── TEST-001.1.toml
专辑标签
为了显示更加有序的专辑列表,元数据仓库以 Tag
的形式对专辑、唱片和音轨进行了分类。
专辑标签信息通过 toml
格式描述。
相同类型的专辑标签名称不可重复;不同类型的标签可以通过 <EDITION>:<NAME>
的格式进行有限的重名。
######################################################
# Tag 信息
[[tag]]
#
# Tag 名称 [必填项]
#
# 标签的名称,前后不带空格
name = "THE IDOLM@STER MILLION LIVE"
#
# Tag 本地化名称 [可选项]
#
# 不同语言下的 Tag 名称。仅用于展示,不能用于指代。
names.zh-hans = "偶像大师百万现场"
#
# Tag 类型 [必填项]
#
# 用于区别不同类型的 Tag。所有相同类型的 Tag 实际拥有以该类型名命名的隐式 Parent Tag。
#
# 可选值如下所示:
# artist: 单艺术家
# group: 多艺术家组合
# animation: 动画
# radio: 广播节目
# series: 系列
# project: 企划
# game: 游戏
# organization: 组织、社团、公司
# unknown: 【不建议】默认,未分类
#
# 此外,还有一种特殊的 Tag:
# category: 属性,如 OP、ED、OST
# 用于表示专辑/碟片/音轨的具体属性,这类 Tag 不会出现在 Tag 图中
type = "artist"
#
# Tag 的父级 Tag [可选项]
#
# 父 Tag 必须作为已有的 Tag 存在。
included-by = ["project:THE IDOLM@STER"]
#
# Tag 的子 Tag [可选项]
#
# 指定子 Tag 时会自动创建对应的 Tag。如果该 Tag 已经存在,检查时会抛出一个错误。
#
# 对结构复杂的 Tag 不建议使用子 Tag。建议以 included-by 上指的形式构建依赖关系,如下例所示。
# 子 Tag 可以在 Album 信息中使用。
#
# 子 Tag 根据 includes 中的字段名区分类型。
includes = ["series:THE IDOLM@STER LIVE THE@TER DREAMERS"]
[[tag]]
name = "THE IDOLM@STER Million Live Theater Days"
names.zh-hans = "偶像大师百万现场剧场时光"
type = "game"
included-by = ["project:THE IDOLM@STER MILLION LIVE"]
JSON 交换格式
用于描述仓库元数据的 TOML
格式便于书写和阅读,但受到各语言实现的限制,解析可能存在一些问题。
本文定义了 JSON 格式的同义替代,以规范各实现对仓库元数据进行交换时的信息格式。
标识符
数据交换中通过以下两种标识符指代光盘和音轨。
// 专辑标识符
type AlbumIdentifier = string; // UUID
// 光盘标识符
interface DiscIdentifier {
// 专辑 ID
album_id: AlbumIdentifier;
// 光盘 ID
disc_id: number;
}
// 音轨标识符
type TrackIdentifier = DiscIdentifier & {
// 音轨 ID
track_id: number;
}
专辑信息
// 专辑信息
interface AlbumInfo {
// 专辑 ID
album_id: AlbumIdentifier;
// 专辑名称
title: string;
// 专辑类型
edition?: string;
// 专辑品番
catalog: string;
// 专辑艺术家
artist: string;
// 专辑的发行日期
date: string;
// 音乐类型
type: TrackType;
// 光盘信息
discs: DiscInfo[];
}
// 专辑详细信息
type AlbumDetail = AlbumInfo & {
// 专辑详细艺术家
artists?: Artists;
// 专辑标签
tags?: string[];
// 光盘信息
discs: DiscDetail[];
}
// 光盘信息
interface DiscInfo {
// 光盘名称
title?: string;
// 光盘品番
catalog: string;
// 光盘艺术家
artist?: string;
// 音乐类型
type?: TrackType;
// 音轨信息
tracks: TrackInfo[];
}
// 光盘详细信息
type DiscDetail = DiscInfo & {
// 光盘详细艺术家
artists?: Artists;
// 光盘标签
tags?: string[];
// 音轨信息
tracks: TrackDetail[];
}
// 音轨信息
interface TrackInfo {
// 音轨标题
title: string;
// 音轨艺术家
artist?: string;
// 音乐类型
type?: TrackType;
}
// 音轨详细信息
type TrackDetail = TrackInfo & {
// 音轨详细艺术家
artists?: Artists;
// 音轨标签
tags?: string[];
}
// 详细艺术家
type Artists = ExtendedArtists & Record<string, string>;
// 详细艺术家的预定义字段
interface ExtendedArtists {
// 歌手
vocal?: string;
// 作曲
composer?: string;
// 作词
lyricist?: string;
// 编曲
arranger?: string;
}
// 音乐类型
type TrackType = "normal" | "instrumental" | "absolute" | "drama" | "radio" | "vocal";
专辑标签
// 标签简介
interface TagInfo {
// Tag 名称
name: string;
// Tag 类型
type: TagType;
}
// 标签详细信息
type TagDetail = TagInfo & {
// Tag 本地化名
names?: Record<string, string>;
// 父级 Tag
included_by?: string[];
// 子 Tag
includes?: Record<TagType, string[]>;
}
// 标签类型
type TagType = "artist" | "group" | "animation" | "radio" | "series" | "project" | "game" | "organization" | "category" | "unknown";
仓库元数据
仓库元数据位于仓库根目录下,文件名为 repo.toml
,用于描述元数据仓库的属性。
示例
[repo]
# 仓库名
name = "Yesterday17's Metadata Repo"
# 仓库使用的元数据仓库描述版本
edition = "1.0"
# 存放专辑的子目录,默认为 ['album']
albums = ['album-beta', 'album']
专辑子目录
专辑可以存放在任意指定的子目录中,方便整理者以目录为单位对专辑信息进行分类。
预构建数据源
⚠️️目前预构建数据源的描述版本为
1.0+alpha-1.1
。
为方便客户端使用仓库数据,元数据仓库中的数据可以单向地构建为 SQLite DB
的形式供客户端读取。
描述文件
预构建数据源需要生成两个文件:repo.json
和 repo.db
。其中 repo.json
被称为描述文件,格式如下:
{
// 仓库上次更新的时间戳,单位为秒
"last_modified": 1642568493
}
仓库元数据(repo_info
)
CREATE TABLE IF NOT EXISTS "repo_info" (
"key" TEXT NOT NULL UNIQUE,
"value" TEXT
);
其中固定的 key
如下:
key | 描述 |
---|---|
repo_name | 仓库名 |
repo_edition | 生成时的元数据仓库的描述版本 |
repo_url | 仓库地址 |
repo_ref | 生成时元数据仓库的 ref |
db_version | 生成时预构建数据源的描述版本 |
专辑(repo_album
)
CREATE TABLE IF NOT EXISTS "repo_album" (
"album_id" BLOB NOT NULL UNIQUE,
"title" TEXT NOT NULL,
"edition" TEXT,
"catalog" TEXT NOT NULL,
"artist" TEXT NOT NULL,
"release_date" TEXT NOT NULL,
"disc_count" INTEGER NOT NULL,
"album_type" TEXT NOT NULL DEFAULT 'normal' CHECK("album_type" IN ('normal', 'instrumental', 'absolute', 'drama', 'radio', 'vocal'))
);
CREATE UNIQUE INDEX "repo_album_index" ON "repo_album" (
"album_id"
);
唱片(repo_disc
)
CREATE TABLE IF NOT EXISTS "repo_disc" (
"album_id" BLOB NOT NULL,
"disc_id" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"artist" TEXT NOT NULL,
"catalog" TEXT NOT NULL,
"track_count" INTEGER NOT NULL,
"disc_type" TEXT NOT NULL DEFAULT 'normal' CHECK("disc_type" IN ('normal', 'instrumental', 'absolute', 'drama', 'radio', 'vocal')),
UNIQUE("album_id","disc_id"),
FOREIGN KEY("album_id") REFERENCES "repo_album"("album_id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "repo_disc_index" ON "repo_disc" (
"album_id",
"disc_id"
);
音轨(repo_track
)
CREATE TABLE IF NOT EXISTS "repo_track" (
"album_id" BLOB NOT NULL,
"disc_id" INTEGER NOT NULL,
"track_id" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"artist" TEXT NOT NULL,
"track_type" TEXT NOT NULL DEFAULT 'normal' CHECK("track_type" IN ('normal', 'instrumental', 'absolute', 'drama', 'radio', 'vocal')),
UNIQUE("album_id","disc_id","track_id"),
FOREIGN KEY("album_id", "disc_id") REFERENCES "repo_disc"("album_id", "disc_id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "repo_track_index" ON "repo_track" (
"album_id",
"disc_id",
"track_id"
);
标签定义(repo_tag
)
CREATE TABLE IF NOT EXISTS "repo_tag" (
"tag_id" INTEGER NOT NULL UNIQUE,
"name" TEXT NOT NULL,
"tag_type" TEXT NOT NULL DEFAULT 'unknown' CHECK("tag_type" IN ('artist', 'group', 'animation', 'radio', 'series', 'project', 'game', 'organization', 'unknown', 'category')),
PRIMARY KEY("tag_id" AUTOINCREMENT),
UNIQUE("name", "tag_type")
);
标签表示(repo_tag_detail
)
CREATE TABLE IF NOT EXISTS "repo_tag_detail" (
"tag_id" INTEGER NOT NULL,
"album_id" BLOB NOT NULL,
"disc_id" INTEGER,
"track_id" INTEGER,
FOREIGN KEY("tag_id") REFERENCES "repo_tag"("tag_id")
);
CREATE INDEX IF NOT EXISTS "repo_tag_detail_index" ON "repo_tag_detail" (
"album_id",
"disc_id",
"track_id"
);
标签本地化名(repo_tag_i18n
)
CREATE TABLE IF NOT EXISTS "repo_tag_i18n" (
"tag_id" INTEGER NOT NULL,
"language" TEXT NOT NULL,
"name" TEXT NOT NULL,
FOREIGN KEY("tag_id") REFERENCES "repo_tag"("tag_id")
);
标签关系(repo_tag_relation
)
CREATE TABLE IF NOT EXISTS "repo_tag_relation" (
"tag_id" INTEGER NOT NULL,
"parent_id" INTEGER NOT NULL,
FOREIGN KEY("tag_id") REFERENCES "repo_tag"("tag_id"),
FOREIGN KEY("parent_id") REFERENCES "repo_tag"("tag_id")
);
补充艺术家(repo_artists
)
CREATE TABLE IF NOT EXISTS "repo_artists" (
"album_id" BLOB NOT NULL,
"disc_id" INTEGER,
"track_id" INTEGER,
"key" TEXT NOT NULL,
"value" TEXT
);
仓库合并
元数据仓库可能存在多个 Album root
,用户也可能会选取多个元数据仓库作为数据来源。因此无论是在整理侧还是用户侧,对多个元数据仓库实现合并都是必要的。
专辑合并
专辑的第一去重标准是品番。当不存在品番冲突时,两个元数据仓库可以无缝合并。而当存在品番冲突时,则需要进行合并操作。下文规范了这种合并操作的具体流程。
专辑相似度
两张品番相同,且相似度极大的专辑很有可能就是同一张。对专辑相似度的判定基于以下基准:
- 专辑品番一致
- 专辑发售日期一致
- 专辑中碟片数量相同
- 专辑中每张碟片中的音轨数量相同
一般而言,上述四条完全相同就可以判定两张专辑为同一专辑。各实现可以在此基础上定义更多的规则。
合并内容
对两张重复的专辑,需要合并的内容如下:
- 专辑、碟片、音轨的
Tag
- 音轨的
artists
字段
专辑 Tag 合并
Tag
合并时,取 Tag
的并集。
artist
合并
artist
合并时,需要对其下所有字段进行合并。当存在冲突时,实现可以选择任意一边的值。
Tag 合并
不同的仓库可能会使用各自不同的 Tag,因此仓库合并时也需要对 Tag 进行合并。
合并时,按照 Tag 的名称进行合并,将两棵 Tag 树合并为一张有向 Tag 图。 合并完成后,该有向图中不能出现环路,当出现环时合并失败,需要人工介入。
音频后端
音频后端是 Anni
中与实际文件系统接触的抽象层。
对 Anni
而言,需要获取的资源主要分为两类:音频资源和图片资源。其中图片资源作为音频资源的附加而存在,如封面等。
根据 Anni
约定,我们有一个近似唯一的 Catalog
。在很多情况下 Catalog
已经足以作为唯一标识,但实际情况下仍然会出现冲突。因此音频后端需要选取另一个更加独立的值作为专辑索引的主键。因此音频后端约定为每张专辑生成唯一的 UUID
作为 album_id
,充当专辑层面的索引。
由此,我们抽象出一个简单的文件系统,其通过 album_id
、disc_id
和 track_id
访问音频资源,通过 album_id
和可选的 disc_id
访问封面。
音频?后端
之所以命名为音频后端,是因为其核心交付内容还是音频,封面的优先度相对较低。
简单之外的价值
我们最常接触的文件系统是本地的文件系统,其访问延迟相对固定且较短。但对于 Anni
,其设计之初针对的便是 Google Drive
,即远程文件系统。
我们的根本目的是获取音频和封面。我们不一定需要一般文件系统的某些特性,比如随机可读性等,但最基本的是一定要满足的。音频后端的抽象使得我们将所有的请求最终归约到向外暴露的两个接口,从而大大简化了音频后端的实现难度。
Trait AnniProvider
此处描述 Rust
实现的 anni-provider
的基本 Trait
。其他语言可模仿设计类似接口。
#![allow(unused)] fn main() { /// AnniProvider is a common trait for anni resource providers. /// It provides functions to get cover, audio, album list and reload. #[async_trait] pub trait AnniProvider { /// Get album information provided by provider. async fn albums(&self) -> Result<HashSet<Cow<str>>, ProviderError>; /// Get audio info describing basic information of the audio file. async fn get_audio_info(&self, album_id: &str, disc_id: u8, track_id: u8) -> Result<AudioInfo, ProviderError>; /// Returns a reader implements AsyncRead for content reading async fn get_audio(&self, album_id: &str, disc_id: u8, track_id: u8, range: Range) -> Result<AudioResourceReader, ProviderError>; /// Returns a cover of corresponding album async fn get_cover(&self, album_id: &str, disc_id: Option<u8>) -> Result<ResourceReader, ProviderError>; /// Reloads the provider for new albums async fn reload(&mut self) -> Result<(), ProviderError>; } }
其中获取返回的类型由 ResourceReader
和 AudioResourceReader
描述:
#![allow(unused)] fn main() { pub type ResourceReader = Pin<Box<dyn AsyncRead + Send>>; pub struct AudioInfo { /// File extension of the file pub extension: String, /// File size of the file pub size: usize, /// Audio duration of the file pub duration: u64, } /// AudioResourceReader abstracts the file result a provider returns with extra information of audio pub struct AudioResourceReader { /// Audio info pub info: AudioInfo, /// File range pub range: Range, /// Async Reader for the file pub reader: ResourceReader, } }
可用后端
Anni
目前支持的后端列表如下:
- 文件系统
-
Google Drive
:通过Google Drive
的团队共享盘实现音频仓库的协作整理
文件系统
简介
常规的文件系统后端,通过系统调用读取目录结构和文件。
属性
属性名 | 类型 | 说明 |
---|---|---|
root | string | 文件后端的根目录 |
Google Drive
简介
以 Google Drive
作为文件存储部分的后端,通过 Google Drive API v3
与 Google
服务器进行通信。
认证方式
OAuth2
OAuth2
是最常规的验证方式。用户通过 OAuth
同意屏幕授予 Anni
其申请的权限,Anni
通过得到的 Token
向 Google
服务器发出请求。
通过 OAuth2
认证,我们至少需要如下基本参数:
参数 | 说明 |
---|---|
client_id | OAuth 客户端 ID |
client_secret | OAuth 客户端 Secret |
project_id | 项目的唯一标识符 |
服务帐号(Service Account)
Service Account
是另一种认证方式。通过增加 Service Account
,我们可以实现细粒度的权限控制。
文件访问
Google Drive
中网盘大致可分为两类:个人盘和团队盘。
音频仓库与 Annil
文件后端本质只是一个抽象后的文件系统,实际的文件分发过程仍需要一个服务端程序的参与。或者更准确地说,一套协议的参与。
这套协议需要实现的功能有:
- 对客户端的访问进行鉴权,判断其是否有权限访问仓库内的文件
- 鉴权通过后,从合适的音频后端中获得文件,并将文件交付给客户端
同时,由于通常我们会拥有复数个音频后端,因此音频仓库需要在单个接口下提供对不同后端的透明访问。
在 Project Anni
中,实现了这一系列操作的就是 Annil
(Anni Library
)。
Anni 音频仓库协议
版本信息
⚠️️ 在协议版本迈入
1.0
之前,任何改动都有可能发生。
最后修改:2022 年 05 月 10 日 协议版本:0.5.0
定义
Anni
音频仓库(Anni Audio Library
,以下简称音频仓库)是指实现了 Anni
音频仓库协议中定义 API
的服务端应用程序。其核心功能有三:
- 对用户身份的鉴定
- 对分享的权限认证及限制
- 向用户分发资源(包括音频和封面)
跨域约定
Anni
音频仓库协议约定所有请求的返回中都包含头部:
Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Allow-Headers: Authorization
并建议带有:
Access-Control-Allow-Origin: *
各实现可以允许用户选择能够进行跨域请求的域名。
返回状态码
Anni
音频仓库协议使用了以下 HTTP 状态码。
状态码 | 含义 |
---|---|
400 | 请求非法 |
200 | 请求成功 |
403 | Token 校验失败或权限不足 |
404 | 资源不存在 |
鉴权
对请求者的身份鉴定通过 JWT
(JSON Web Token
) 的形式进行(下文称作 Token
)。本协议不限制签名的方式,主要针对 Claim
部分作出规定。各实现需要对请求者的身份进行鉴权,以判断是否有对应资源的权限。
协议规定了两种 Token
:用户 Token
和分享 Token
。用户 Token
的使用者为音频仓库的正式用户,部分用户拥有创建分享 Token
的权限;分享 Token
的使用者为任意用户,可以在分享 Token
的限定范围内获取 Anni
音频仓库中的资源。下文中除特殊说明外,限定的访问范围均为有用户 Token
和有对应资源访问权限的分享 Token
。
Anni
音频仓库协议允许以下两种鉴权方式中的任意一种。客户端可以根据需要选用其中任意一种调用。
Authorization 头部
当使用 Authorization
头部时,客户端需要将合法的 JWT
携带于 Authorization
头部中,不需要增加 Bearer
前缀。
Anni
音频仓库能否正确处理带 Bearer
的请求属于未定义行为。
该方式对所有请求均有效。
?auth=
和 Authorization
类似,客户端也可以选择将 JWT
夹带在 URL
的 query
中。仅对 GET 请求可用。
用户身份鉴定
以下为用户 Token
(User Token
) 的 Claim
部分样例:
{
// Token 签发的时间
"iat": 1615788479,
// JWT 类型,必须为 user
"type": "user",
// 用户标识,注意不要包含用户信息,只要能标记用户即可
"user_id": "rua",
// 当 Token 支持分享时,会携带以下信息
"share": {
// 可选,用于标识 key
"key_id": "409e2d92-d2c5-4dff-81e6-fe832e1b06d0",
// secret 字段用于对分享 Token 签名
"secret": "c398313c-b086-47ce-9a91-edfa5c93bd73",
// 可选,表示允许签署的分享范围,省略则表示无限制
// 结构与 /albums 的返回相同
// 该字段若存在则必须为数组,其他情况均未定义。
"scope": [],
// @deprecated `scope` 的别名,不建议使用。
// 协议进入 1.0 后会将该字段删除。
"allowed": []
}
}
音频仓库信息
获取音频仓库信息的 API
定义为 GET /info
,所有用户都可以请求。
返回结果如下例:
{
// 音频仓库的版本描述
// 用于客户端展示
"version": "Annil v0.1.0",
// 当前运行的 Annil 音频仓库协议版本
// 用于客户端比对其支持的能力
"protocol_version": "0.2.1",
// 音频仓库最近一次数据更新时间
// 用于客户端缓存 `/albums` 请求结果
"last_update": 1639631487
}
获取可用专辑
由于音频仓库整合了多个音频后端,因此继承了所有音频后端的可用音频数据。
获得可用专辑的 API
定义为 GET /albums
,仅合法的用户 Token
可以正常请求。
响应内容包含当前仓库所有可用的专辑 album_id
列表,如下例:
// 以下 UUID 不代表实际内容
[
// KSLA-0155~9/2018-12-29
"4992994f-9ede-56c2-b614-83bf89840e10",
// TS-0003/2015-05-20
"c12705be-6f2d-5195-aa53-5325ce9b780a",
// KSLA-0178/2020-12-16
"896a00d4-f93d-57b2-aa0a-52b1cf521c63"
]
Anni
音频仓库协议建议音频仓库在响应中携带 ETag
头部,以便客户端缓存。
客户端实现指导
Anni
音频仓库建议,客户端每次启动时携带 If-None-Match: <previous-etag-value>
头部请求 GET /albums
,并判断:
- 若响应状态码为
304
,则无需更新; - 若响应状态码为
200
,则根据返回结果自动更新索引,并向用户提示。
特别地,第一次启动时客户端无需携带 If-None-Match: *
头部,直接请求 GET /albums
并自动更新索引,并向用户提示。
资源分享
Project Anni
的设计目的是 Self Host
使用,因此存在验证用户身份的环节,非认证用户无法访问仓库中的资源。但是对部分资源的分享需求是真实存在的。因此 Anni
音频仓库草案提出了细粒度的资源共享方案。
资源共享通过 JWT
的方式进行鉴权。
签署 Token
用户 Token
的 Claim
部分定义了 share
字段,用于存储资源分享所需的相关凭据。
客户端需以 share.secret
字段作为 key
,对分享 Token
进行 HS256
签名。
分享 Token(Share Token)
Header
部分如下例:
{
"typ": "JWT",
"alg": "HS256",
// 分享 Key 的 ID,当用户 Token 中携带时须包括在分享 Token 内
"kid": "409e2d92-d2c5-4dff-81e6-fe832e1b06d0"
}
Claim
部分如下例:
{
// 该 Token 的签发时间,即分享的开始时间
"iat": 1615788479,
// 该 Token 的失效时间,即分享的结束时间
// 服务端有权拒绝向不存在该字段的 Token 提供资源,播放器实现需考虑到这一点并相应地对用户作出提示
"exp": 1615874879,
// JWT 类型,必须为 share
"type": "share",
// 分享的音频,以 JSON Object 的形式表示
// 作为 key 的字符串表示该分享允许访问者访问的专辑 album_id
// value 表示该分享允许访问者访问的唱片号(disc_id)和音轨号(track_id)
"audios": {
// KSLA-0155~9/2018-12-29
"4992994f-9ede-56c2-b614-83bf89840e10": {
// Disc 2(KSLA-0156), Track 2
"2": [2],
// Disc 3(KSLA-0157), Track 3
"3": [3]
},
// KSLA-0178/2020-12-16
"896a00d4-f93d-57b2-aa0a-52b1cf521c63": {
"1": [1, 2, 3]
}
}
}
资源分发
音频仓库接收用户的请求,从可用的音频后端获取数据,并将获得的数据转发给用户。
由于 Anni
约定中的各专辑均带有封面信息,因此资源分发同时支持音频和封面。
音频分发
音频分发基于音频所在 Disc
的 album_id
、 disc_id
和 track_id
。用户获取音频的前提是其提供的 Token
允许其访问对应的资源。
获得音频的 API
定义为 GET /{album_id}/{disc_id}/{track_id}
。通过 HEAD /{album_id}/{disc_id}/{track_id}
可以只获取 Header
信息。
音频偏好
考虑到客户端的不同网络场景和带宽因素,以下的参数可以向音频仓库表明客户端的格式偏好。
参数名 | 说明 | 可选项 |
---|---|---|
quality | 客户端期望的音频质量。 | low 、medium 、high 、lossless |
需要注意的是,客户端传递的参数仅为客户端偏好,对音频仓库仅有指导意义,不代表实际结果。
音频仓库在对参数进行处理时,应以枚举而非字符串拼接的形式进行,以防止命令注入。
请求范围
为了达到更好的播放效果,Annil
实现可以选择实现 HTTP
请求范围。
当音频仓库支持某一音频资源的单一范围请求时,HEAD
和 GET
请求的返回中必须带有 Accept-Ranges: bytes
。当该头部不存在,或为 none
时,表示音频仓库不支持该音频文件的单一范围请求。
Anni
音频仓库协议暂不支持多重范围请求。
当客户端向音频仓库通过 GET
请求某一范围时,下文所述的返回头部不保证可用。
返回头部
当成功获取音频时,返回的头部会携带如下额外信息:
Key | 含义 |
---|---|
Content-Type | 音频的 MIME Type ,根据情况可能为 audio/flac 、audio/mp3 等 |
X-Origin-Type | 源文件的 MIME Type ,当与 Content-Type 不同时表明音频经过了转码 |
X-Origin-Size | 源文件的大小,单位为字节(Byte ) |
X-Duration-Seconds | 音频的长度,单位为秒 |
X-Audio-Quality | 音频的实际音质,可选值为 low 、medium 、high 、lossless |
封面分发
封面分发基于音频所在专辑的 album_id
和可选的 disc_id
。获取封面无需 Token
。
获得封面的 API
定义为 GET /{album_id}/{disc_id}/cover
或 GET /{album_id}/cover
,默认 disc_id
为 1
。
返回头部
当成功获取封面时,返回的头部会携带如下额外信息:
Key | 含义 |
---|---|
Content-Type | 图片的 MIME Type ,当前定义中只能是 image/jpeg |
管理接口
Annil 定义了如下的管理接口,供音频仓库的管理员使用。
以下所有 API
均为可选,对接时须确认各接口是否可用,以及公网可访问性等细节。接口仅包含最小定义,在满足下文定义行为的前提下,各实现可以自行添加部分参数以实现功能扩展。
访问管理接口时需要包含 Authorization
头部,该头部的获取方式不在本文规定范围之内。
管理接口不应允许跨域访问。
签署用户 Token
签署用户 Token
的 API
定义为 POST /admin/sign
。
{
"user_id": "rua",
"share": true
}
返回结果为签名后的用户 Token
。
资源热更新
热更新 Annil
中各个 Provider
中资源的 API
定义为 POST /admin/reload
。
热更新的过程是同步的,更新结束后请求才会返回。
不同的音频仓库实现
Annil
本身只是音频仓库实现的标准。我们鼓励不同的音频仓库实现。
实现列表
名称 | 地址 |
---|---|
annil-rs | https://github.com/ProjectAnni/anni/tree/master/annil |
go-annil | https://github.com/ProjectAnni/go-annil |
annil-rs
annil-rs
是最简单的音频仓库实现之一。其本身只实现了对 HS256
签名的 JWT
的校验,只要校验通过就允许用户访问资源。
配置文件
[server]
# 音频仓库名称
name = "My Server"
# 仓库监听的地址
listen = "localhost:3614"
# 仓库签名 JWT 的密钥
hmac-key = "a hmac key"
# 分享使用的 secret
share-key = "49040a2d-55e7-41a2-b4b5-45527879536e"
# 分享使用的 key-id
share-key-id = "b5b605d8-9349-45f5-bbf6-75690cb58090"
# 调用管理接口的 token
admin-token = "reload-token"
# 音频后端名称为 default
[backends.default]
# 是否启用该音频后端
enable = true
# 音频后端类型
type = "file"
# FileBackend 的参数,根目录
root = "/path/to/music"
# 使用严格目录格式(true) 或约定目录格式(false)
# 目前尚未实现
strict = false
# Google Drive 后端
[backends.drive]
enable = true
type = "drive"
corpora = "drive"
# 团队盘 ID
drive-id = "0AJIJiIDxF1yBUk9PVA"
# 初始 Token 路径
initial-token-path = "/data/annil.token"
# Token 的存放路径
token-path = "/tmp/ruaaaa/annil.token"
# drive 的缓存设置
[backends.drive.cache]
# 缓存根目录
root = "/tmp"
# 缓存最大容量,0 表示不限制
max-size = 1073741824 # 1GB
# 元数据
[metadata]
# 仓库地址
repo = "https://github.com/ProjectAnni/repo"
# 使用的分支
branch = "master"
# 元数据存放的路径
base = "/tmp/ruaaaa"
# 每次启动/reload 时是否更新仓库
pull = true
[Draft] Annil 音轨扩展
⚠️️目前的音频扩展仍处于
Draft
状态,尚未完全定稿。欢迎随时提出意见或建议。
针对互联网上流通较为广泛的单音轨音频,原本的 Annil
只能以专辑的形式分散管理。Annil
音轨扩展约定了针对单音轨音频的处理方式。
音频文件名
我们规定音频的文件名统一为 {标题} - {艺术家}
。其中艺术家名称使用逗号 ,
分隔,不采用 Anni
音频约定中的形式,以减短文件名长度。
以下文件名均为有效文件名:
三月雨 - 洛天依,Wing翼
霜雪千年 - 洛天依,乐正绫,COPY
我们不建议使用 Various Artists
作为音轨的艺术家,因为容易产生冲突。
品番生成
为了最大程度兼容 Annil
协议,音轨扩展依然采用 Annil
的专辑-音轨形式,因此需要对每个音轨生成独一无二的品番。
品番通过 UUIDv5
生成,Name
为音频文件名,Namespace
如下:
7696064f-415c-518e-a828-4d9019c3cf93
该预设 UUID
通过 ns:DNS
、anni.rs
生成。
上文中音频文件名对应的品番如下:
102e82a3-9949-5e09-a9e2-59895fa3f194 # 三月雨 - 洛天依,Wing翼
b8105a43-7a4e-5797-9084-f00f26abd829 # 霜雪千年 - 洛天依,乐正绫,COPY
音频获取
在尝试获取 Annil
音轨扩展定义的音频时,和一般的 Annil
音频相同,需要提供品番和音轨号。其中品番由上文定义,音轨号则强制为 1。
Anniv
⚠️️ 目前的
Anniv
仍处于Draft
状态,尚未完全定稿。欢迎随时提出意见或建议。
⚠️️ 目前
Anniv
协议的版本为1
。
Anniv
是统合用户体验的中心化平台,提供注入播放列表(歌单)、Annil
Token 同步等功能。
从定位上来看,它类似于 last.fm
,但提供了针对 Project Anni
的独特功能。
阅读导览
对 API 的描述分为 Endpoint
、请求(请求参数)、返回(返回参数)、参数表示和错误列表。每项中均可能存在客户端设计指引或示例。
请求
所有带有 body
的请求均为 application/json
,参数均写在 body
内。
对于不带 body
的请求,参数以 Param
形式传递。
返回
返回的状态码一般均为 200 Ok
,body
均为 JSON
格式,结构如下:
interface ResponseBody<T> {
status: number;
message?: string;
data: T;
}
在后续的描述中,我们只描述类型 T
的结果,省略外层包裹的 JSON Object
。
当 status
为 0
时,data
中包含类型为 T
的返回数据。
返回状态
返回状态为 6
位数字,具体划分如下:
领域 | 分类 | 编号 |
---|---|---|
1 代表基本错误代码,2 代表特性错误代码,9 代表通用错误代码 | 2 位数字 | 3 位数字 |
无错误
无错误时,返回的 status
为 0
。
通用错误
通用错误如下表所示:
错误代码 | 详情 |
---|---|
900000 | 站点致命错误 |
900001 | 数据库连接错误 |
901000 | 数据写入错误 |
901001 | 数据读取错误 |
902000 | 内容不存在 |
902001 | 无权访问 |
902002 | 用户未登录 |
902003 | 非法参数 |
902004 | 内容已存在 |
错误说明
大部分接口都需要用户登录,因此本文档仅在不需要用户登录的接口处作特殊标注。除此之外,所有接口均可能返回 902002 用户未登录
错误。
通用结构
本文中默认定义 Id
结构如下:
type Id = { id: string };
基本信息
接口 /api/info
描述了 Anniv
后端的基本信息,包括站点名称、说明、特性开启状态等。
该接口无需用户登录。
示例
{
"site_name": "Anniv 测试站点",
"description": "该站点仅用于测试,请勿滥用。",
"protocol_version": "1",
"features": ["2fa", "invite"]
}
错误列表
错误代码 | 详情 |
---|---|
101000 | 站点维护中 |
用户系统
Anniv
的用户系统分为注册、登录、注销三个部分。
错误代码一览
错误代码 | 详情 |
---|---|
102000 | 昵称不可用 |
102001 | 注册邮箱不可用 |
102010 | 邮箱或密码错误 |
102020 | 用户不存在 |
对用户密码的预处理
客户端在向服务端发送密码之前,须对用户的明文密码进行一次 sha256
处理。该步骤仅在一定程度上减少用户原文密码的泄露,但仍可以通过诸如彩虹表的方式破解。
用户信息
Endpoint
GET /api/user/info
返回
返回当前用户信息(UserInfo
)。
返回参数
参数名 | 类型 | 详情 |
---|---|---|
user_id | string | 用户 ID |
email | string | 邮箱 |
nickname | string | 昵称 |
avatar | string | 头像链接 |
参数表示
interface UserInfo {
user_id: string;
email: string;
nickname: string;
avatar: string;
}
修改密码
Endpoint
PATCH /api/user/password
请求参数
参数名 | 类型 | 详情 |
---|---|---|
old_password | string | 原密码 |
new_password | string | 新密码 |
返回
原密码正确时,将用户密码修改为新密码。
错误列表
错误代码 | 详情 |
---|---|
102010 | 原密码错误 |
用户简介
Endpoint
GET /api/user/intro
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
user_id | string | 用户 ID |
返回
返回指定用户的用户简介(UserIntro
)。
返回参数
参数名 | 类型 | 详情 |
---|---|---|
user_id | string | 用户 ID |
nickname | string | 昵称 |
avatar | string | 头像链接 |
参数表示
interface UserIntro {
user_id: string;
nickname: string;
avatar: string;
}
错误列表
错误代码 | 详情 |
---|---|
102020 | 用户不存在 |
修改简介
Endpoint
PATCH /api/user/intro
请求参数
参数名 | 类型 | 详情 |
---|---|---|
nickname | string | 昵称 |
avatar | string | 头像 |
返回
当用户已登录时,修改用户昵称与头像。
错误列表
错误代码 | 详情 |
---|---|
102000 | 昵称不可用 |
用户注册
该接口无需用户登录。
Endpoint
POST /api/user/register
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
password | string | 密码 |
email | string | 邮箱 |
nickname | string | 昵称 |
avatar | string | 头像链接 |
返回
注册成功时,返回用户信息(UserInfo
)。
参数表示
interface UserRegisterBody {
email: string;
password: string;
nickname: string;
avatar: string;
}
错误列表
错误代码 | 详情 |
---|---|
102000 | 注册昵称不可用 |
102001 | 注册邮箱不可用 |
102002 | 注册密码格式错误 |
用户登录
该接口无需用户登录。
Endpoint
POST /api/user/login
请求
请求参数
参数 | 类型 | 详情 |
---|---|---|
email | string | 邮箱 |
password | string | 密码 |
返回
登录成功时,返回用户信息(UserInfo
)。
参数表示
interface UserLoginBody {
email: string;
password: string;
}
错误列表
错误代码 | 详情 |
---|---|
102010 | 邮箱或密码错误 |
用户退出
Endpoint
POST /api/user/logout
客户端设计指引
当用户退出成功时,建议清理对应客户端的 Cookie
。
用户注销
注销当前用户帐号。
Endpoint
POST /api/user/revoke
参数表示
interface UserRevokeBody {
// 暂空
}
客户端设计指引
用户注销接口一旦请求,用户立即注销。客户端在设计时应考虑增加多道确认步骤。
错误列表
错误代码 | 详情 |
---|---|
102020 | 用户不存在,该错误可能出现于客户端保留了注销前的凭据 |
使用情况
该接口无需用户登录。
Endpoint
POST /api/user/register/check
请求
请求参数
参数 | 类型 | 详情 |
---|---|---|
email | string | 邮箱 |
错误列表
错误代码 | 详情 |
---|---|
102001 | 注册邮箱不可用 |
播放列表
播放列表是顺序的歌曲列表,用户可以通过播放列表对零散的歌曲进行整理。
播放列表相关的功能有获取、创建、修改、删除。
错误一览
错误代码 | 详情 |
---|---|
103000 | 用户播放列表数已达上限 |
103001 | 播放列表中歌曲数量已达上限 |
103002 | 播放列表不存在 |
103003 | 修改类型非法 |
103003 | 歌曲 ID 非法 |
获取播放列表
获取指定播放列表
Endpoint
GET /api/playlist
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
id | string | 播放列表标识符 |
返回
请求成功时,返回播放列表的全部信息。
返回参数
参数名 | 类型 | 详情 |
---|---|---|
id | string | 播放列表标识符 |
name | string | 播放列表名称 |
description | string | 播放列表说明 |
owner | string | 播放列表拥有者 ID |
is_public | boolean | 是否公开 |
items | (PlaylistItem & Id)[] | 播放列表中的内容 |
cover | PlaylistCover | 播放列表封面,为 null 时客户端应使用该 Playlist 的第一个 Track 封面 |
last_modified | number | 播放列表最后修改时间戳(秒) |
PlaylistItem
可以为 PlaylistItemTrack
、PlaylistItemDummyTrack
或 PlaylistItemAlbum
,分别表示歌曲、占位歌曲和专辑,详见下方类型定义。
每种 PlaylistItem
类型均含有:
参数名 | 类型 | 详情 |
---|---|---|
description | string | 对歌曲的说明文本 可为空 |
info | T | 该类型的附加数据 |
PlaylistItem & Id
类型在各 PlaylistItem
的基础上增加了:
参数名 | 类型 | 详情 |
---|---|---|
id | string | 歌曲在歌单中的 id ,歌单内唯一。 |
示例
{
"id": "3",
"name": "测试",
"description": "测试",
"owner": "3",
"is_public": true,
"cover": {
"album_id": "cfbde6ad-e365-4435-bfda-5a475899fb6b",
"disc_id": 1
},
"items": [
{
"id": "3",
"type": "normal",
"description": "interesting",
"info": {
"album_id": "cfbde6ad-e365-4435-bfda-5a475899fb6b",
"disc_id": 1,
"track_id": 1,
"title": "ハルノヘッドフォン",
"artist": "春日部ハル(篠田みなみ)",
"album_title": "t7s Longing for summer"
}
}
],
"last_modified": 1145141919810
}
参数表示
interface PlaylistInfo {
// 播放列表 ID
id: string;
// 播放列表标题
name: string;
// 播放列表说明
description?: string;
// 播放列表创建者
owner: string;
// 是否公开
is_public: boolean;
// 封面
cover: PlaylistCover;
}
type PlaylistCover = DiscIdentifier | null; // 专辑/光盘封面
// 表示无封面的结构
type EmptyPlaylistCover = { album_id?: "" };
interface BasePlaylistItem<Info> {
// 内容类型
type: string;
// 内容说明
description?: string;
// 内容附加信息
info: Info;
}
// 普通音轨
interface PlaylistItemTrack extends BasePlaylistItem<TrackInfoWithAlbum> {
type: "normal";
}
// 普通音轨(无元数据)
export interface PlaylistItemPlainTrack
extends BasePlaylistItem<TrackIdentifier> {
type: "normal";
}
// 占位音轨
interface PlaylistItemDummyTrack extends BasePlaylistItem<Required<TrackInfo>> {
type: "dummy";
}
// 普通专辑
interface PlaylistItemAlbum extends BasePlaylistItem<AlbumIdentifier> {
type: "album";
}
type PlaylistItem =
| PlaylistItemDummyTrack
| PlaylistItemTrack
| PlaylistItemAlbum;
type PlaylistPatchItem =
| PlaylistItemDummyTrack
| PlaylistItemPlainTrack
| PlaylistItemAlbum;
错误列表
错误代码 | 详情 |
---|---|
103002 | 播放列表不存在 |
902001 | 禁止访问私有播放列表 |
获取指定用户播放列表
Endpoint
GET /api/playlists
请求参数
参数名 | 类型 | 详情 |
---|---|---|
user_id | string | 用户 ID,缺省为当前用户 |
返回
若指定用户为自己,则返回所有播放列表。否则仅返回公开的播放列表。
返回 PlaylistInfo[]
。
创建播放列表
Endpoint
PUT /api/playlist
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
name | string | 播放列表名称 |
description | string | 播放列表说明 |
is_public | boolean | 是否公开 |
cover | PlaylistCover | 播放列表封面 |
items | PlaylistPatchItem[] | 初始加入播放列表的内容 |
返回
处理完成后,返回新创建的播放列表信息(Playlist
)。
参数表示
interface CreatePlaylistBody extends Omit<PlaylistInfo, "id" | "owner"> {
items: PlaylistPatchItem[];
}
错误列表
错误代码 | 详情 |
---|---|
103000 | 用户播放列表数已达上限 |
103001 | 播放列表中歌曲数量已达上限 |
修改播放列表
Endpoint
PATCH /api/playlist
请求
对播放列表的修改分为三个部分:增加歌曲、删除歌曲和排列歌曲。
请求参数
参数名 | 类型 | 详情 |
---|---|---|
id | string | 增加歌曲的播放列表 |
command | string | 表示修改的类型,可选项为 info 、append 、remove 、reorder 或 replace |
payload | 见参数表示 |
返回
修改成功后,返回修改后的播放列表信息(Playlist
)。
参数表示
export type PatchPlaylistRequestBody =
| PatchPlaylistInfoBody
| AppendPlaylistBody
| RemovePlaylistItemBody
| ReorderPlaylistBody
| ReplacePlaylistItemBody;
// 修改 Playlist 本身信息
export type PatchPlaylistInfoBody = PatchPlaylistBodyType<
"info",
PatchedPlaylistInfo
>;
// 在 Playlist 末尾增加音乐
export type AppendPlaylistBody = PatchPlaylistBodyType<
"append",
PlaylistPatchItem[]
>;
// 通过 ID 从 Playlist 中删除音乐
export type RemovePlaylistItemBody = PatchPlaylistBodyType<"remove", string[]>;
// 通过 ID 对 Playlist 重排序
export type ReorderPlaylistBody = PatchPlaylistBodyType<"reorder", string[]>;
// 修改 Playlist 中部分内容
export type ReplacePlaylistItemBody = PatchPlaylistBodyType<
"replace",
(PlaylistPatchItem & Id)[]
>;
type PatchPlaylistBodyType<K, P> = Id & {
command: K;
payload: P;
};
type PatchedPlaylistInfo = Partial<Omit<PlaylistInfo, "id" | "owner">>;
错误列表
错误代码 | 详情 |
---|---|
103002 | 播放列表不存在 |
103003 | 修改类型非法 |
103003 | 歌曲 ID 非法 |
删除播放列表
Endpoint
DELETE /api/playlist
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
id | string | 播放列表标识符 |
错误列表
错误代码 | 详情 |
---|---|
103002 | 播放列表不存在 |
103003 | 非法修改类型 |
Annil Token 管理
考虑到多客户端的使用场景,Anniv
实现了一套对 Annil Token
的管理系统,供客户端之间同步 Token
信息。
错误一览
错误代码 | 详情 |
---|---|
104000 | 用户 Token 存储数已达上限 |
104001 | Token 不存在 |
获取 Token
Endpoint
GET /api/credential
返回
返回包含所有请求 Token & Id & Controlled
的数组。
返回参数
Token
的定义如下所示:
参数名 | 类型 | 详情 |
---|---|---|
name | string | Annil 名称 |
url | string | Annil 站点地址 |
token | string | Annil Token 内容 |
priority | number | 客户端尝试访问时的优先级 |
Controlled
的定义如下所示:
参数名 | 类型 | 详情 |
---|---|---|
controlled | boolean | 该 Token 是否为受控 Token ,不可修改 |
对于受 Anniv
管理的 Token
,其 controlled
属性为 true
。客户端无法修改除 priority
之外的任何属性;对于用户手动添加的 Token
,则可以修改其他字段。
参数表示
type GetTokensResponse = (Token & Id & Controlled)[];
type Controlled = { controlled: boolean };
interface Token {
name: string;
url: string;
token: string;
priority: number;
}
新增 Token
用户可以通过该接口,令 Anniv
保存自定义的 Token
信息。
Endpoint
POST /api/credential
请求
新增请求的类型为 Token
。
返回
新建成功后,返回创建完成的 Token
信息(Token & Id & Controlled
)。
错误列表
错误代码 | 详情 |
---|---|
104000 | 用户 Token 存储数已达上限 |
修改 Token
用户可以修改当前存在的 Token
信息。
Endpoint
PATCH /api/credential
请求
修改请求的类型为 Partial<Token> & Id
。
错误列表
错误代码 | 详情 |
---|---|
104001 | Token 不存在 |
删除 Token
Endpoint
DELETE /api/credential
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
id | string | 待删除 Token 的 ID |
错误列表
错误代码 | 详情 |
---|---|
104001 | Token 不存在 |
104002 | 无法删除受控 Token |
信息导出格式
Annil
提供了分享 Token
的创建功能,但这只是资源分享的基石。
对于音频分享而言,我们需要的不只是音频文件的集合,还需要包含顺序、元数据等在内的一系列信息。而这一切需求汇总到一起,信息导出格式便应运而生。
播放列表导出格式
type ExportedPlaylist = ExportedPlaylistInfo & ExportedPlaylistMetadata & ExportedPlaylistToken;
interface ExportedPlaylistInfo {
// 播放列表名称
name: string;
// 播放列表简介
description: string;
// 播放列表封面
cover: PlaylistCover;
// 按顺序存放播放列表中的所有歌曲
songs: ExportedTrackList[];
}
interface ExportedPlaylistMetadata {
// 存放播放列表中的元数据
// 使用 AlbumInfo,不含 artists 和 tags 信息
metadata: Record<AlbumIdentifier, AlbumInfo>;
}
interface ExportedPlaylistToken {
// 实际访问音频文件的 Token
tokens: ExportedToken[];
}
interface ExportedTrackList extends DiscIdentifier {
tracks: number[];
}
interface ExportedToken {
server: string;
token: string;
}
样例
{
"name": "Example",
"description": "Example description",
"cover": {
"album_id": "e54fdcc4-662e-4e10-b91a-73984ce8248e",
"disc_id": 1
},
"songs": [
{
"album_id": "e54fdcc4-662e-4e10-b91a-73984ce8248e",
"disc_id": 1,
"tracks": [1, 2, 3]
},
{
"album_id": "09174545-a173-44fe-b489-0d078a2023c2",
"disc_id": 1,
"tracks": [1]
}
],
"metadata": {
"e54fdcc4-662e-4e10-b91a-73984ce8248e": {
"album_id": "e54fdcc4-662e-4e10-b91a-73984ce8248e",
"title": "僕は存在していなかった",
"edition": null,
"catalog": "SRCL-9520",
"artist": "22/7",
"date": "2017-09-20",
"type": "normal",
"discs": [
{
"title": "僕は存在していなかった",
"artist": "22/7",
"catalog": "SRCL-9520",
"type": "normal",
"tracks": [
{
"title": "僕は存在していなかった",
"artist": "22/7",
"type": "normal"
},
{
"title": "地下鉄抵抗主義",
"artist": "22/7",
"type": "normal"
},
{
"title": "11人が集まった理由",
"artist": "22/7",
"type": "normal"
},
{
"title": "僕は存在していなかった -off vocal ver.-",
"artist": "22/7",
"type": "instrumental"
},
{
"title": "地下鉄抵抗主義 -off vocal ver.-",
"artist": "22/7",
"type": "instrumental",
"tags": []
},
{
"title": "11人が集まった理由 -off vocal ver.-",
"artist": "22/7",
"type": "instrumental"
}
]
}
]
},
"09174545-a173-44fe-b489-0d078a2023c2": {
"album_id": "09174545-a173-44fe-b489-0d078a2023c2",
"title": "ハナノイロ",
"edition": null,
"catalog": "LACM-4796",
"artist": "nano.RIPE",
"date": "2011-04-20",
"type": "normal",
"discs": [
{
"title": "ハナノイロ",
"artist": "nano.RIPE",
"catalog": "LACM-4796",
"type": "normal",
"tracks": [
{
"title": "ハナノイロ",
"artist": "nano.RIPE",
"type": "normal"
},
{
"title": "バーチャルボーイ",
"artist": "nano.RIPE",
"type": "normal"
},
{
"title": "花残り月",
"artist": "nano.RIPE",
"type": "normal"
}
]
}
]
}
},
"tokens": [
{
"server": "https://site-url",
"token": "jwt token here"
},
{
"server": "https://site2-url",
"token": "xxx"
}
]
}
注:客户端需要根据 JWT Body 中的 audios
为每个专辑选择可用的 Anni Library 服务器及 Token 。
专辑元数据
Anniv
提供对元数据仓库中元数据进行索引的能力。
结构定义
type AlbumTitle = { album_title: string };
type TrackInfoWithAlbum = TrackIdentifier & Required<TrackInfo> & AlbumTitle;
获取 Tag 列表
GET /api/meta/tags
返回
返回包含所有 Tag
的数组,类型为 TagInfo[]
。
专辑信息
通过 album_id
获得专辑元数据的接口。支持同时获取多张专辑的元数据信息。
Endpoint
GET /api/meta/album
请求参数
参数名 | 说明 |
---|---|
id[] | 待获取元数据的专辑 ID 列表 |
返回
返回以 album_id
为键,专辑元数据或空为值的 Object
。
当查询成功时,值为专辑的 AlbumDetail
,否则为 null
。
按 Tag 检索专辑
通过 Tag 检索归属于该 Tag 下的专辑列表。
该接口属于检索接口,标签使用「向上合并」的语义。即计算专辑标签时,需要取专辑内所有专辑标签、光盘标签和音轨标签的并集。具体定义见 专辑信息 - 标签。
Endpoint
GET /api/meta/albums/by-tag
参数
参数名 | 类型 | 说明 |
---|---|---|
tag | string | Tag 名 |
recursive | boolean | 是否递归检索子 Tag 所包含专辑 |
返回
AlbumDetail[]
错误列表
错误代码 | 详情 |
---|---|
902000 | Tag 不存在 |
获取 Tag 关系
GET /api/meta/tag-graph
返回
返回 tag
依赖关系的邻接表,类型表示为 Record<string, string[]>
。
Key-Value
对应的是标签与其所包含的子标签。字符串格式为 <EDITION>:<NAME>
,标签类型不可省略。
搜索
Endpoint
GET /api/search
请求参数
参数名 | 类型 | 说明 |
---|---|---|
search_albums | bool | 是否搜索专辑 |
search_tracks | bool | 是否搜索音轨 |
search_playlists | bool | 是否搜索播放列表 |
keyword | string | 搜索关键字 |
返回
返回 SearchResult
。
参数列表
interface SerachResult {
albums?: AlbumDetail[];
tracks?: TrackInfoWithAlbum[];
playlists?: PlaylistInfo[];
}
分享管理
Anniv
基于信息导出格式实现单曲、专辑和播放列表的分享。
错误一览
错误代码 | 详情 |
---|---|
105000 | 用户分享数已达上限 |
105001 | 分享链接不存在 |
获取分享链接内容
该接口无需用户登录。
Endpoint
GET /api/share
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
id | string | 分享链接的 ID |
返回
返回分享链接的实际内容,结构为 ExportedPlaylist
。
错误列表
错误代码 | 详情 |
---|---|
105001 | 分享链接不存在 |
获取分享链接列表
Endpoint
GET /api/share/
返回
返回当前用户创建的所有分享链接列表。
返回参数
参数名 | 类型 | 详情 |
---|---|---|
id | string | 分享链接的 ID |
date | timestamp | 分享创建日期 |
创建分享链接
创建分享链接时,客户端需要提供待分享资源的元数据和供被分享人播放音乐的分享 Token
。Anniv
负责记录用户提供的上述信息,并存储一份用户指定播放列表的快照。
Endpoint
POST /api/share
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
info | ExportedPlaylistInfo | 分享的必要信息 |
metadata | ExportedPlaylistMetadata.metadata | 可选的元数据。当未指定时,元数据由 Anniv 提供 |
albums | Record<AlbumIdentifier, Id['id']> | 待签发专辑与 Annil Token 的对应关系 |
返回
请求成功时,返回分享链接的短部。
错误列表
错误代码 | 详情 |
---|---|
105000 | 用户分享数已达上限 |
删除分享链接
Endpoint
DELETE /api/share
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
id | string | 待删除分享链接的 ID |
错误列表
错误代码 | 详情 |
---|---|
902000 | 分享链接不存在 |
播放统计
⚠️ 该部分尚未设计完成
Anniv
提供基本的播放统计功能。
播放记录
Endpoint
POST /api/stat
请求
请求参数
请求参数类型为 SongPlayRecord[]
。
参数表示
interface SongPlayRecord {
track: TrackIdentifier;
// 播放开始的时间
at: number[];
}
客户端设计指引
当用户播放时间过短(如少于 10 秒,少于全曲的 1/3 等,具体由播放器决定)时,不应该汇报本次播放。
获取当前用户播放记录
Endpoint
GET /api/stat/self
请求
请求参数
参数名 | 详情 |
---|---|
from | 统计区间开始时间戳(秒) |
to | 统计区间结束时间戳(秒),默认为请求提交时间 |
返回
返回 SongPlayRecordResult[]
,按播放次数降序排序。
参数表示
interface SongPlayRecordResult {
track: TrackIdentifier;
count: number;
}
获取歌曲的播放统计
Endpoint
GET /api/stat/song
请求
请求参数
参数名 | 详情 |
---|---|
album_id | 请求歌曲所属专辑的 album_id |
disc_id | 请求歌曲的 disc_id |
track_id | 请求歌曲的 track_id |
返回
若请求成功,返回对应曲目的播放统计。
返回参数
参数名 | 类型 | 详情 |
---|---|---|
count | number | 请求曲目的播放次数 |
获取当前用户播放历史记录
Endpoint
GET /api/stat/self/history
请求
请求参数
参数名 | 详情 |
---|---|
limit | 返回条目数量上限,默认为 10 |
offset | 返回条目的偏移量,默认为 0 |
返回
返回 HistoryRecord[]
,按播放时间降序排序。
参数表示
interface HistoryRecord {
track: TrackIdentifier;
at: number;
}
获取当前用户单曲播放次数
Endpoint
GET /api/stat/self/song
请求
请求参数
参数名 | 详情 |
---|---|
album_id | 请求歌曲所属专辑的 album_id |
disc_id | 请求歌曲的 disc_id |
track_id | 请求歌曲的 track_id |
from | 统计区间开始时间戳(秒),默认为 0 |
to | 统计区间结束时间戳(秒),默认为请求提交时间 |
返回
若请求成功,返回对应曲目的播放次数。
返回参数
参数名 | 类型 | 详情 |
---|---|---|
count | number | 请求曲目的播放次数 |
喜欢
用户喜欢的单曲列表与播放列表。
单曲
获取喜欢列表
GET /api/favorite/music
返回参数
返回 TrackInfoWithAlbum[]
,按照添加时间倒序排序。
添加单曲
PUT /api/favorite/music
请求参数
请求参数为一个 TrackIdentifier
对象。
删除单曲
DELETE /api/favorite/music
请求参数
请求参数为一个 TrackIdentifier
对象。
专辑
获取收藏专辑列表
GET /api/favorite/album
返回参数
返回 AlbumIdentifier[]
,按照添加时间倒序排序。
添加收藏专辑
PUT /api/favorite/album
请求参数
参数名 | 类型 | 详情 |
---|---|---|
album_id | AlbumIdentifier | 待添加专辑的 AlbumIdentifier |
删除收藏专辑
DELETE /api/favorite/album
参数名 | 类型 | 详情 |
---|---|---|
album_id | AlbumIdentifier | 待添加专辑的 AlbumIdentifier |
订阅播放列表
获取全部已订阅的播放列表
GET /api/favorite/playlist
返回参数
interface FavoriteListInfo {
playlist_id: string;
name: string;
owner: string;
}
返回 FavoriteListInfo[]
,按照添加时间倒序排序。
订阅播放列表
PUT /api/favorite/playlist
请求参数
参数名 | 类型 | 说明 |
---|---|---|
playlist_id | string | 需要订阅的列表 |
错误列表
错误代码 | 详情 |
---|---|
902004 | 列表已订阅 |
取消订阅
DELETE /api/favorite/playlist
请求参数
参数名 | 类型 | 说明 |
---|---|---|
playlist_id | string | 需要取消订阅的列表 |
歌词
Anniv
歌词 API
提供了灵活的音乐歌词管理策略。
歌词分发
GET /api/lyric
请求参数
参数名 | 类型 | 说明 |
---|---|---|
album_id | string | 请求歌词的专辑 ID |
disc_id | number | 请求歌词的碟片 ID |
track_id | number | 请求歌词的单曲 ID |
返回参数
interface LyricResponse {
source: LyricLanguage;
translations: LyricLanguage[];
}
interface LyricLanguage {
// 歌词语言
language: LanguageCode;
// 歌词类型
type: "text" | "lrc";
// 歌词内容
data: string;
// 贡献者
contributor: UserIntro;
// 来源
source: string;
// 最后修改
last_modified: number;
}
涉及到的语言代码(LanguageCode
)参考 RFC5646 标准。
错误列表
错误代码 | 详情 |
---|---|
902000 | 该歌曲不存在可用歌词 |
歌词修改/创建
PATCH /api/lyric
当歌词不存在时,自动创建该歌词。
返回无错误不代表歌词立即可用,可能会有审核环节。因此客户端不应期望创建后立即有可用的歌词,应积极尝试获取。
请求参数
参数名 | 类型 | 说明 |
---|---|---|
album_id | string | 请求歌词的专辑 ID |
disc_id | number | 请求歌词的碟片 ID |
track_id | number | 请求歌词的单曲 ID |
type | "text" | "lrc" | 修改后歌词的类型 |
lang | LanguageCode | 修改歌词的语言 |
source | string | 歌词来源 |
data | string | 修改后的歌词 |
错误代码
错误代码 | 详情 |
---|---|
901000 | 歌词修改无法写入,可能的原因有:修改 lrc 歌词为 text 歌词 |
特性
为了控制一些可选功能的启用与否,Anniv
定义了一系列特性。特性的启用与否由 Anniv
的管理员决定,开启后会增加一些处理步骤。
适用范围
特性的适用范围描述了特性实现的具体部分,以描述其在客户端实现中应处的具体位置。
目前有效的适用范围有:
- 用户登录
- 用户注册
- 用户注销
- ……
参数列表
当特性对原有功能做加法时,通常会增加一定的输入参数。参数列表指定了这些参数的名称和类型。
错误列表
当某一特性开启时,往往会增加特性相关的特定错误。错误列表指定了错误代码和其代表的详细错误内容。
接口列表
某些特性可能会定义其独有的接口。接口列表负责描述这些接口的请求参数和返回格式。
邀请(invite)
适用范围
- 用户注册
请求参数
参数名 | 类型 | 详情 |
---|---|---|
invite_code | string | 邀请码 |
错误列表
错误代码 | 详情 |
---|---|
201000 | 邀请系统未开启 |
201001 | 邀请码无效 |
201002 | 邀请码与被邀请用户不符 |
201003 | 邀请码已到达使用上限 |
两步验证(2fa)
特性定义
-
2fa
:表示服务端允许两步验证 -
2fa_enforced
:表示服务端强制启用两步验证。
依赖2fa
,特性列表中不应只出现2fa_enforced
而不存在2fa
。
当服务端强制启动两步验证时,所有新注册用户均须在注册时开启两步验证。当用户未启用两步验证时,只有以下接口可用:- 用户登出
- 用户注销
- 绑定
2FA
访问其他接口均会产生错误:
202000
:用户未启用两步验证。
客户端应引导已注册但未开启两步验证的用户尽快开启两步验证。
适用范围
- 用户注册(
POST /api/user/register
) - 用户登录(
POST /api/user/login
) - 用户注销(
POST /api/user/revoke
) - 用户信息(
GET /api/user/info
) - 用户修改密码(
PATCH /api/user/password
)
TODO: 列举出用到 2FA
的所有位置
请求参数
参数名 | 类型 | 详情 |
---|---|---|
2fa_secret | string | 两步验证的 Secret ,注册时由客户端生成,发送至服务端。 |
2fa_code | \d{6} | 两步验证的代码。当用户未开启两步验证时,该字段填入任何值均可。 |
影响结构
当 2fa_enforced
启用时,以下所有新增参数均为 Required
。
用户注册
interface UserRegisterBody {
// ...
"2fa_secret"?: string;
"2fa_code"?: string;
}
用户登录
interface UserLoginBody {
// ...
"2fa_code"?: string;
}
interface UserInfo {
// ...
"2fa_enabled"?: boolean;
}
用户注销
interface UserRevokeBody {
"2fa_code"?: string;
}
用户信息
interface UserInfo {
// ...
"2fa_enabled"?: boolean;
}
用户修改密码
interface UserChangePasswordBody {
// ...
"2fa_code"?: string;
}
绑定 2FA
当 2FA
为可选项时,用户可以使用该接口绑定两步验证。当 2fa_enforced
启用时,未开启两步验证的用户可以通过该接口开启两步验证。
Endpoint
POST /api/features/2fa
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
2fa_secret | string | 两步验证的 Secret ,由客户端生成后发送至服务端。 |
2fa_code | \d{6} | 两步验证的代码。 |
错误列表
错误代码 | 详情 |
---|---|
202001 | 两步验证代码错误 |
202002 | 两步验证 Secret 非法 |
202004 | 用户已启用两步验证,无法重复绑定 |
取消绑定 2FA
用户可以使用该接口取消当前已绑定的两步验证。
Endpoint
DELETE /api/features/2fa
请求
请求参数
参数名 | 类型 | 详情 |
---|---|---|
2fa_code | \d{6} | 两步验证的代码。 |
错误列表
错误代码 | 详情 |
---|---|
202000 | 用户未启用两步验证 |
202001 | 两步验证代码错误 |
202003 | 两步验证已达尝试上限 |
完整错误列表
错误代码 | 详情 |
---|---|
202000 | 用户未启用两步验证 |
202001 | 两步验证代码错误 |
202002 | 两步验证 Secret 非法 |
202003 | 两步验证已达尝试上限 |
202004 | 用户已启用两步验证,无法重复绑定 |
禁止注册(close)
适用范围
- 用户注册。
错误列表
错误代码 | 详情 |
---|---|
203000 | 站点关闭注册 |
兼容性
当 invite
特性启用时,close
特性无效。
预构建数据源(metadata-db)
适用范围
- 元数据获取
获取预构建数据源
Endpoint
GET /api/meta/db/*
可以访问 预构建数据源 中定义的数据源和描述文件。
Annisonic
Annisonic
是 Project Anni
在开发过程中为尽早替换掉当前正在使用的 Airsonic
而实现的。
其在最小程度上实现了 Subsonic API
,以在客户端未开发完成的当下提供半完整的音频服务。
实现的 API
Annisonic
实现了以下 Subsonic API
:
支持或部分支持的 API
支持的 API
已打勾,待支持的 API
未打勾,不支持的 API
以 删除线 删去。
System
-
ping
-
getLicense
Browsing
-
getMusicFolders
-
getIndexes
-
getMusicDirectory
-
getGenres
-
getArtists
-
getArtist
-
getAlbum
-
getSong
-
getVideos
-
getVideoInfo
-
getArtistInfo
-
getArtistInfo2
-
getAlbumInfo
-
getAlbumInfo2
-
getSimilarSongs
-
getSimilarSongs2
-
getTopSongs
Album/song lists
-
getAlbumList
-
getAlbumList2
-
getRandomSongs
-
getSongsByGenre
-
getNowPlaying
-
getStarred
-
getStarred2
Searching
-
search
-
search2
-
search3
Media retrieval
-
stream
-
download
-
hls
-
getCaptions
-
getCoverArt
-
getLyrics
-
getAvatar
兼容性支持 API
-
getPlaylists
-
getUser
无计划支持的 API 类别
Playlists
Media annotation
Sharing
Podcast
Jukebox
Internet radio
Chat
User management
Bookmarks
Media library scanning
已测试的客户端
Annisonic
下测试过的客户端会罗列在此处,并指明配置的详细设置和存在的问题。
DSub
DSub
是 Android
平台下的一款 Subsonic
播放器。
配置
- Browse By Tags: Off
- Sync Enabled: Off
- Authorization Basic headers: Off
存在问题
- 缓存时会直接从
0%
跳变到100%
Soundwaves
配置
- Don't Validate SSL Certificates: Off
- MAX BIT RATE: No Limit
存在问题
暂无
Clementine
Clementine 是一款兼容 SubSonic 协议的桌面音乐播放器。
存在问题
暂无
部署向导
元数据仓库
我们假设读者已将需要的元数据仓库 clone
到 /repo
下。
配置文件
config.toml
中存放了 Annisonic
部署相关的所有配置信息。
[server]
# 监听的地址
listen = "127.0.0.1:1710"
username = "anni"
password = "anni"
[repo]
# 元数据仓库的位置
root = "/repo"
[annil]
# Annil 的地址
# 确保外网可以访问,Annisonic 会直接 302 重定向
server = "https://annil.example.org"
# Annil 的用户 Token
token = "ey..."
构建
我们使用以下命令构建:
# 构建
cargo build --target x86_64-unknown-linux-musl --release
# 压缩
upx target/x86_64-unknown-linux-musl/release/annisonic
运行
将 annisonic
和 config
放置于同一目录,然后通过以下命令启动即可:
RUST_LOG=info ./annisonic
Anni 命令行工具
Anni
命令行工具的设计初衷是通过自己实现的一整套工具,替代之前使用的包括 shnsplit
、metaflac
和 cuetools
等分散的工具,并解决一些其不能或未涵盖的问题,如编码处理。
在下文中,我们统一将 Anni 命令行工具称为 anni-cli
。
功能列表
anni-cli
目前的功能分为以下五个部分:
flac
:针对flac
音频格式的工具集split
:对整轨音频分割的工具,用于替代shnsplit
convention
:对anni
音频约定进行检查的工具repo
:对Anni
元数据仓库进行操作的工具
flac
针对日常使用的场景,anni
的 flac
部分(需要)实现的功能列表如下:
-
输出信息
- 输出块信息
-
输出元数据信息
- 以纯文本格式输出
-
以
JSON
格式输出 - 支持同时输出多个文件的元数据信息
- 输出图片块中数据(导出封面)
-
修改元数据信息
-
修改
vendor
- 增加元数据
- 删除元数据
- 修改元数据
-
删除指定块
- 删除元数据块
- 删除图片块
-
增加指定块
- 导入封面
-
修改
split
以音轨文件(如 cue
)和整轨音频文件(如 wav
)作为输入,分轨音频作为输出的音频分割工具。
支持格式
格式 | 依赖工具 | 输入 | 输出 |
---|---|---|---|
wav | - | ☑ | ☑ |
flac | flac | ☑ | ☑ |
ape | mac | ☑ | ☒ |
tak | tak | ☑ | ☒ |
功能列表
TODO:支持除 CUE 以外的音轨文件。
-
音频分割
-
指定输入
- 指定输入目录
- 指定 CUE 文件
- 指定音频文件
- 日志复述输入内容
- 分割进度:进度条
-
指定输出
- 指定输出文件名格式
- 指定输出目录
- 输出文件已存在时提示用户是否覆盖(分割前检查)
-
指定输入
-
元数据导入
- 从 CUE 读取各音轨元数据
-
封面导入
- 指定封面文件
- 日志复述作为封面的图片文件名
-
自动寻找封面文件,匹配优先级如下
- 与音频文件同名的图片
- 与 CUE 文件同名的图片
-
名称为
cover
的图片 - 同目录下任意名称的图片
convention
anni convention
可以执行 Anni 约定检查。
使用
anni convention check /path/to/flac
该命令会打印出对应的检测报告,并在可能时会提供解决方案,通过 --fix
执行解决方案。
检查内容
- 对标签存在性的检测(包括必须标签、可选标签以及在此之外的标签)
- 对重复标签的检测
-
对标签
Key
存在小写字母的检测 - 对标签值为空的检测
- 对标签值前后的空白字符检测
- 对艺术家格式的验证
- 对日期格式的验证
- 对间隔点的验证
- 对文件名和歌曲标题一致性的检测
repo
专辑
-
导入专辑
- 从 CUE 导入
- 从分轨音频文件导入
-
从远程提供者导入
- 从 VGMdb 导入
- 从 iTunes 导入
- 从 IM@SDB 导入
- 覆盖已有数据
-
修改专辑
- 打开默认文本编辑器
-
应用专辑
- 将专辑元数据写回到分轨音频文件
-
删除专辑
- 用户确认
元数据
-
输出元数据
- 输出标题
- 输出艺术家
- 输出发售日期
-
输出分轨 CUE
- 输出 Project Anni 的 REM COMMENT(可选关闭)
- 输出 TOML
- 输出 JSON
-
输出 Tag
- 输出 Tag 信息
- 输出 Tag 下专辑信息
workspace
anni workspace
提供了一系列对本地音频整理工作空间的管理命令。
术语表
名称 | 英语 | 简称 | 详情 |
---|---|---|---|
音频整理工作空间 | Workspace | 工作空间 | anni workspace 工作的目录 |
工作目录 | 工作空间根目录下的 .anni 目录,由 anni 控制状态 | ||
用户目录 | 用户视图中使用的,实际工作的目录 | ||
专辑目录 | 用户目录下,实际存放专辑的子目录 | ||
受控专辑目录 | 工作目录下,实际存放专辑的子目录 | ||
受控链接 | 用户目录下,链接到 工作目录 的符号链接,名称为 .album | ||
专辑状态:未跟踪 | Untracked | 未跟踪 | 用户通过 create 创建专辑,但未将专辑跟踪时的状态 |
提交 | Commit | - | 通过 add 将专辑从工作空间中移交到音频仓库的行为 |
专辑状态:已跟踪 | Committed | 已跟踪 | 用户通过 add 将专辑结构交由 anni 托管后的状态 |
发布 | Publish | - | 通过 publish 将专辑从工作空间中移交到音频仓库的行为 |
专辑状态:已发布 | Published | 已发布 | 用户通过 publish --soft 发布专辑后的状态 |
专辑状态:悬垂 | Dangling | - | 用户目录下存在专辑,但工作目录中不存在 AlbumID 对应目录,或符号链接错误 |
专辑状态:待回收 | Garbage | - | 用户目录中不存在该专辑,且该专辑在工作目录中对应为空 |
专辑锁 | 在对某一专辑目录进行状态变更时,为防止并行冲突放置的锁。通常为命名为 .lock 的文件 |
工作空间
音频整理工作空间看起来有点拗口的样子,之后我们就用工作空间来代替好了
工作空间的由来是为了替代掉原有的,没有任何约束的任意目录。工作空间的存在目的有两个:
- 通过固定的目录结构,防止误操作的出现。
常见的误操作包括在保种的目录应用元数据、在保种的目录修改音频内嵌封面等修改原文件内容的行为。全是血泪史 - 通过一定的机制,简化音频整理的操作。
在规定了目录结构后,音频目录就可以和元数据文件关联起来。元数据文件的品番在整理过程中可能(由于填写错误)存在变更,但AlbumID
一般不会变化。
目录结构
工作空间的结构分为两部分:工作目录和用户目录。
工作目录
工作目录是名为 .anni
的隐藏目录,其结构如下:
├── objects
│ ├── 10
│ │ └── 77
│ │ └── 1077d96b-8fd3-4585-a6ab-8eef265a48df
│ ├── 25
│ │ └── eb
│ ├── d2
│ │ └── 74
│ ├── e
│ │ └── ff
│ └── fc
│ └── 50
├── repo
│ ├── album
│ ├── tag
│ ├── repo.toml
│ └── Readme.md
└── config.toml
该目录中保存了音频整理所需的两大元素:音频资源和元数据仓库。目前根目录下定义的内容如下:
- 音频资源,以严格目录格式存放于
objects
目录下,层级为 2。当专辑目录下存在.publish
文件时,该专辑的状态为已发布。 - 元数据仓库,位于
repo
目录。 config.toml
,负责记录一些额外的配置信息。
用户目录
工作空间中除工作目录的部分都是用户目录。用户目录结构基本满足约定目录结构,但存在以下区别:
- 专辑目录中碟片数量的限定(
[n Discs]
)将不起作用 - 碟片目录的名称没有任何限制
示例
一个典型的用户目录结构如下:
├── [A] halca
│ ├── [210519][VVXX-01011] キミがいたしるし
│ │ ├── 01. キミがいたしるし.flac -> ./.album/1/1.flac
│ │ ├── 02. もういいや。.flac -> ./.album/1/2.flac
│ │ ├── 03. キミがいたしるし (TV Size).flac -> ./.album/1/3.flac
│ │ ├── 04. キミがいたしるし (Instrumental).flac -> ./.album/1/4.flac
│ │ ├── 05. もういいや。 (Instrumental).flac -> ./.album/1/5.flac
│ │ ├── .album -> ../../.anni/objects/10/77/1077d96b-8fd3-4585-a6ab-8eef265a48df
│ │ └── cover.jpg -> ./.album/cover.jpg
│ └── [220817][VVXX-01253] 誰彼スクランブル/あれこれドラスティック【Complete Edition】
│ ├── 01. 誰彼スクランブル.flac
│ ├── 02. あれこれドラスティック.flac
│ ├── 03. reprise.flac
│ ├── 04. あれこれドラスティック (halca Ver.).flac
│ ├── 05. 誰彼スクランブル (Remixed by Snail's House).flac
│ ├── 06. 時としてバイオレンス (Remixed by キノシタ).flac
│ ├── 07. 誰彼スクランブル (TV Size).flac
│ ├── 08. あれこれドラスティック (TV Size).flac
│ ├── 09. 誰彼スクランブル (Instrumental).flac
│ ├── 10. あれこれドラスティック (Instrumental).flac
│ ├── 11. reprise (Instrumental).flac
│ ├── .album -> ../../.anni/objects/e1/5/e1057cfd-cd38-4280-bccf-4a48d4cf39f6
│ └── cover.jpg
└── .anni
├── config.toml
├── objects
│ └── 10
│ └── 77
│ └── 1077d96b-8fd3-4585-a6ab-8eef265a48df
│ ├── 1
│ │ ├── 1.flac
│ │ ├── 2.flac
│ │ ├── 3.flac
│ │ ├── 4.flac
│ │ ├── 5.flac
│ │ └── cover.jpg
│ └── cover.jpg
└── repo
观察上述目录结构中的 [A] halca
部分。用户部分有以下几个特征:
- 专辑目录可以位于任意嵌套层级下。
- 在整理过程中,目录中所有文件都是普通文件。
如[220817][VVXX-01253] 誰彼スクランブル/あれこれドラスティック【Complete Edition】
。 - 在文件基本已经确定后,可以通过
workspace add
将专辑状态更新为已跟踪。此时,所有文件都会被转移到工作目录中,原目录下仅保留符号链接。
如[210519][VVXX-01011] キミがいたしるし
。 - 专辑的目录下都存在名为
.album
的符号链接,链接到工作目录中的实际专辑位置。
因此,不存在.album
的目录会被anni
忽略。anni
只会处理存在专辑指向的目录。
配置文件
工作空间中的配置文件可以用于记录与整理相关的配置。一个简单的配置文件样例如下所示:
[workspace]
publish-to = ["default"]
[library.default]
path = "/home/yesterday17/Music"
layers = 2
命令
init
初始化工作空间。
- 全新创建工作空间
-
--repo
:从指定位置clone
元数据仓库 -
--repo-config
:使用元数据仓库的repo.toml
作为工作空间的配置文件
创建从.anni/config.toml
到.anni/repo/repo.toml
的符号链接。
status
查看工作空间中各专辑的状态。
-
-a
,--album-id
:显示完整的Album ID
,而非 8 位的Album ID
前缀 -
-j
,--json
:输出当前工作空间的完整状态为JSON
格式
create
新建专辑目录。新建的专辑目录状态为未跟踪。
-
-a
,--album-id
:创建专辑的Album ID
-
-d
,--disc-num
:创建专辑对应的光盘数量 -
-f
,--force
:当创建目录的路径存在时,是否强制创建专辑目录
add
将专辑的状态变更为已跟踪。状态变更前,anni
会输出一些信息供对比。
-
-t
,--tags
:变更状态的同时,向元数据仓库导入音频中内嵌的元数据 -
-y
,--yes
:跳过信息检查 -
d
,--dry-run
:不实际变更专辑状态,仅运行流程。该属性搭配-t
使用可以仅导入专辑元数据 -
e
,--editor
:状态变更后,打开元数据仓库中的元数据文件
rm
从工作空间中删除某一专辑。
-
-y
,--yes
:跳过人工确认
update
在已跟踪状态下,对音频、封面或元数据进行更新。
publish
在已跟踪状态下,将元数据和封面写入,并将专辑从工作空间中移动到音频仓库。
专辑发布后,用户目录中的专辑目录也会被删除。
当专辑目录中存在任意非符号链接的正常文件时,发布过程中止。
-
-w
,--write
:在发布前写入元数据和封面,效果与update -tc
相同。 -
-u
,--uuid
:通过Album ID
而非专辑路径发布专辑 -
-s
,--soft
:在专辑发布时,采用移动+软删除的方式替代直接移动目录。发布结束后,专辑状态变更为已发布。工作目录中仍然会保留专辑信息,但用户目录中的专辑会被移除。
fsck
修复一些常见的工作空间问题。
-
-d
,--fix-dangling
:修复状态为悬垂的专辑目录 -
--gc
:清除状态为待回收的专辑目录
serve
启动工作空间的服务端环境,包括:
-
一个基于严格目录格式的
Annil
服务端 -
一个基于
GraphQL
的元数据服务端 -
一个基于
WebSocket
的终端转发服务