开始之前

在一切开始之前,我希望各位能够对 Project Anni 中的各个部分有一个大致的印象。

Project Anni 包括两个部分:对音乐资源的组织和对音频资源的分发。

前者包含整轨切分、元信息编辑、元数据整理等。Anni 提供了切分整轨、检查音频的工具,并提出了元数据仓库的概念将音频元数据和音频文件分离,便于格式化地整理音乐的元数据。

后者包括一套完整的服务端、客户端应用。客户端用于实际的播放,服务端则用于提供客户端所需的音频、封面等资源。我们曾经设想过将分发的形式以 P2P 的方式呈现,但最终还是选择了传统的 C/S 架构,将 Anni 定位为一个便于 Self host 的项目。Anni 服务端的理想部署环境是 NAS,但同时也支持基于 Google DriveVPS 部署方式。

目前,前者的实现已较为完善,但在数据格式及工具的易用性上仍有待优化,暂时没有完全稳定;后者则是目前开发的重点,待敲定的包括 Anniv 的协议细节,各服务端和客户端的实现也正在进行中。

讲在前面

Project Anni 的出现是为了填补「不满足我们需求」的空白,而不是为了纯泛用性而生。

和每一个定制项目一样,Anni 并不一定适合每一个人,我们也不奢求所有用户和我们的需求都完全一致。

同时,作为我用 Rust 编写的第一个较大型项目,这对我而言也是一个 Learn to Rust 的过程,因此代码质量一定有所不足,欢迎各位的 Pull Request

我只希望,在填坑 Anni 的过程中,不要遗忘了初心

前言

云音乐

相信大家都有过这样的经历,在各个音乐平台之间挣扎的经历。常常是平台 A 下架了某一首歌,平台 B 依靠独占吸收来自平台 A 的难民。

这种现象很普遍,倒不如说在版权时代这是再正常不过的风景了。没有版权就不能上架,天经地义的事情。平台不过是希望吸引流量又不希望承担流量带来的法律风险罢了,否则一开始又为何要上架呢?

罢了,下载到本地听吧。

本地音乐

本地音乐的来源就非常广泛了。

一是从各大云音乐平台上下载得到的音频。其是否存在加密并不是什么麻烦的事情,因为音乐终究是要播放的,最不济你从操作系统层面把送给音频 API 的每一个采样都截下来不就拿到 wav 了吗(笑)

二是各大公网音乐包,比如 [Nemuri] 的合集。这种合集的特点是音乐全,格式整齐,通常以统一的格式进行分享(如 FLACMP3 等)。

有了音频文件,再配上音乐播放器就可以和云音乐一样听歌了。本地音乐的一大缺点在于更新,没有了云音乐运营,一切的一切都要靠你自己了。

当然了,这并不是本地音乐唯一的问题。本地音乐最大的问题恰恰在于本地。且不论本地音乐增多后多端的存储成本,单单是音乐的同步就够你喝一壶的。就以基本的 PC、手机、平板三端同步为例,你需要在这三台设备之间反复横跳——一旦某一台设备没有同步,而你恰巧想要听某一张新发售的专辑——噩梦就降临了。

于是,我们发现,云音乐恰巧是为了解决这些痛点而存在的。那我们要回到云音乐平台吗?

自建音乐平台

既希望得到本地音乐的便利,又想要云音乐的便利,这就是自建音乐平台的意义所在了。

最常见的自建音乐平台应该就是 Airsonic 了,这是一个用 Java 写成,基于 Subsonic 的最后一个开源版本 fork 而出的项目,而 Subsonic 则是著名的自建音乐平台。

Airsonic 的存在已经能够满足很多人的需求了,但它还是无法完全满足我当前的需求,原因有以下几点:

  1. Airsonic 的编写语言是 Java,其需要的资源对小型 VPS 而言还是太多了。以我目前运行的 Docker 为例,没有任何人使用的情况下,基本内存占用维持在 800MiB 左右。
  2. 自建音乐平台需要服务器有较大的硬盘空间,而这一点面对只会增多不会减少的音乐资源难以永久实现。目前我整理的音乐资源有 185GiB,这对于我部署 Airsonic20G 小鸡肯定是不现实的。
  3. 为了应对硬盘问题,我使用了 rclone 挂载。但在音乐更新后,Airsonic 需要花费大量的时间(小时级别)重新扫描整个音频目录,读取每一个音频文件的元数据。这么长的时间差完全不能满足「马上就听」的需求。
  4. Airsonic 支持部分更新,根据目录和文件的 lastModifiedTime 决定是否扫描。但 Google Drive 对于这部分的实现并不遵循一般文件系统的行为,对内层文件的更新并不会导致外层目录的修改时间变化,宣告了 Airsonic 的这个特性完全无法使用。
  5. Airsonic 诸如音乐上传、下载、last.fm 的功能是我不需要的,而且没有办法完全关闭。使用这些功能可能会导致未定义行为(如上传到 rclone 挂载的目录可能会打乱原本的目录结构)。尽管我可以不使用这些功能,但我不能要求和我一起整理资源,共用平台的朋友永远都不使用这些功能。
  6. 更新缓慢,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 协议后借用的 DSubAirsonic 客户端,只是还算能用的状态。

Anni 是如何工作的?

从组成上来说,Anni 分为三个部分:

  1. 音频文件相关:音频元信息处理、整轨切分、元数据导入/导出
  2. 音乐仓库相关:仓库结构管理、数据整理
  3. 服务端/客户端相关

音频文件相关

整轨切分

ArchLinux Wiki 上专门有一页专门介绍如何切分 CUE,其中用到的就是 shntools 中的 shnsplit。配合上 cuetoolscuetag.sh,可以相对方便地实现整轨的切分和音频元数据的导入。

笔者持续了这样的切分方式一段时间(如下),但最终还是放弃了。

NAME="${$(ls *.cue)%.cue}" && shnsplit -f "$NAME.cue" -o "flac flac --picture cover.jpg -o %f -" "$NAME.wav" -t "%n. %t"

放弃的原因很大一部分其实源于这个脚本没写好,但实际我也有点厌倦了这种有点复杂的切分方式。虽然 flacon 或许也是个不错的选择,但其默认的设置并不讨喜,首先是会强制把封面图片转小并强制替换,其次是输出标签的 key 都是小写的。

此外,shntoolCUE 读取的实现也存在一些问题:其无法识别 UTF-8 With BOM 的文件。隔壁的方案是使用 tail,于是脚本就变得更加复杂了(笑)

Anni 对这方面的支持程度可以看出这个项目成长的轨迹。早期版本实现的是读取 CUE 并输出可工作的 bash 脚本。从本质上来说,这样的工作方式和上面直接用脚本的方式没什么不同,但区别在于:

  1. CUE 的解析更可控了,可以进行一些基于内容的检查
  2. 实现的 CUE 部分还可以用于项目的其他部分

而目前的版本则是基本实现了 shnsplit 的对应功能,可以根据 CUE 切分并输出 wavflacape 格式的音频。Anni 实现了 wav 的解析与切分;而其他格式则是调用对应的解码器解码为 wav 后再进行切分,与 shnsplit 一致。

目前使用的 CUE 解析库为 ProjectAnni/cue_sheetforkleoschwarz/cue_sheetAnni 在这个实现的基础上修复了 REM 解析的问题。

音频元数据处理

音频元数据的可靠来源为发行商的官方网站、歌手的个人主页、企划/组合的官网和 BK;较可靠来源为 iTunes 上的专辑信息,VGMDB 等数据库收集的非一手资料;次可靠来源为 CUE 中附带的元信息等。

参差不齐的数据需要经过一定的整理后才能写入音频文件,作为自带的元数据存储。由于音频元数据的变动需要修改整个音频文件,更新较为麻烦,因此我们希望尽量减少对音频本身的修改,力求一次到位,将正确的元数据写入后就不再变动。

元数据导入/导出

在整理「THE IDOLM@STER」的音乐资源时,我拿到的资源是这样的:

  1. 所有资源均为 FLAC 格式,以罗马音命名
  2. 有内嵌元数据,但同样有大量罗马音填充

理想的整理方式是这样的:

  1. 从一个目录的 FLAC 中将专辑的元数据导出到文本
  2. 和可信源进行对比
  3. 将整理后的元数据重新导入 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-handleranni-clap-handler-derive

处理 Clap 子命令调用的 handler 库。

鸣谢

成员/贡献者

TODO

开源项目

本项目的出现离不开前人项目们的引导和支持,因此在此特别列出。

项目名链接
cuetoolshttps://github.com/svend/cuetools
shntoolhttp://shnutils.freeshell.org/shntool/
Airsonichttps://github.com/airsonic/airsonic
flachttps://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 是存在于 FLACVorbis 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-AType-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)作为间隔点。

如果遇到其他的间隔点,请修正为上述符号。在编码转换时需额外留意。

参考

  1. 中点(·)在UTF-8和GBK转换中的问题 · Dark Side

对斜线(/)的说明

由于斜线被各大操作系统用作目录之间的分隔符,因此无法用作目录或文件的名称。

当为专辑命名时,必须对专辑目录名作此修正。可以根据语义替换成分数号()或除号(),否则替换成全角斜线()。

当为专辑内歌曲命名时,写入标签的歌曲名称不作任何修正,文件名中以全角斜线()替换斜线(/)。

Anni 会自动修正歌曲的文件名中的斜线。

对波浪线(~)的说明

在音频标题中,统一使用 Unicode 全角波浪线 U+FF5E)作为波浪线。

当遇到 Wave Dash U+301C)时,请修正为上述符号。

参考

  1. チルダ - Wikipedia

专辑封面

专辑对应的封面图片,只接受 image/jpg

专辑目录下须存在 cover.jpg 以表示封面。音乐文件须内嵌封面。

清晰度

封面需要尽量清晰,建议在 800x800 以上。

封面来源

可以在发行商官网、iTunes、Hi-Res 平台、VGMdb 等网站寻找。

文件名

音频文件

音乐资源以 {tracknumber:02}. {title} 命名,扩展名为 flac

封面文件

封面文件名必须为 cover.jpg,文件名全小写。

品番

品番(Catalog) 是存在于(几乎)每一张专辑上的编号。

Anni 约定其对于各专辑近似唯一,在不严格的情况下可以作为专辑的主键。

连续表示

当多个 Catalog 连号时,使用 ~ 表示连贯,取最低位连续。如表示 VVCL-1466VVCL-1467 两张专辑须使用 VVCL-1466~7,而非 VVCL-1466~67

私有品番域

对于没有品番的专辑,Anni 约定以 @ 开头的品番为私有品番域。

手动分配

CD 属于 BD 的一部分,且 BDCD 预留了 Catalog,则按 CD 对应的 Catalog 填写。

单张 CD 属于 BD 的一部分,但 BD 没有为 CD 预留 Catalog,则按 BD 对应的 Catalog 填写。

CD 存在多张唱片但只分配了一个 Catalog,如原 CD 定义了品番为 TEST-001,存在两张 Disc。则此时两张 Disc 的品番分别为:

  • @TEST-001-01
  • @TEST-001-02

此处的 @ 使用虚拟品番域,表示该品番并非真实品番;后缀的 -01-02DiscId,补足两位数字。

约定目录结构

除存储专辑的专辑目录以外,Anni 的目录并没有太多的约束。不过我们建议通过这样的层级组织你的音乐仓库:

  1. 顶级目录
  2. 分类目录(可选)
  3. 专辑目录

该目录结构格式称为约定目录结构

目录类型

目录分为如下类型:

标识符(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 年中 206 位数字日期;和以 - 隔开的 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 COLORSCD 存在多个系列:

  • 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 约定对音频文件内嵌的标签有了严格的约束,但从实现的角度来看,如果所有的元数据都从音乐文件中获取,那么势必会导致如下问题:

  1. 音乐库存与音乐资源强关联。在得到音乐资源并写入音频文件的标签之前,我们无法更新音乐仓库的信息,即「无法提前准备内容」。
  2. 数据的更新伴随着对文件的扫描。对于存储于网络和慢速硬盘上的资源,扫描需要消耗大量的时间,而这是我们希望避免的。

基于上述原因,我们将音频的元数据独立出来,以静态的形式存放。称为「仓库」是因为元数据仓库建议通过 Git 仓库的形式维护。

目录结构

元数据仓库的基本结构如下所示:

├── album
│   ├── KSLA-0178.toml
│   └── @META-DUPLICATED
│     ├── @META-DUPLICATED.0.toml
│     └── @META-DUPLICATED.1.toml 
└── tag
│ └── [Anime] 神様になった日.toml
├── repo.toml

专辑存放的目录由 repo.toml 指定。

专辑信息

对专辑信息的描述存放于元数据仓库下指定的专辑存放目录,以专辑的品番作文件名。

专辑信息通过 toml 格式描述。

艺术家

专辑信息中有 artistartists 字段用于填写艺术家信息。其中 artist 字段较为简单,而 artists 字段则可以表示更加精确的艺术家信息。

artist 字段

专辑、唱片和音轨都拥有 artist 字段,用于表示主要的艺术家信息。主艺术家的选取规则如下:

  1. 当该曲目的类型为 Normal 时,主艺术家为 Vocal。特别地,对于 VOCALOID 或其他歌声合成软件制作的歌曲,Vocal 由P主和歌姬共同构成。
  2. 当该曲目的类型为 Instrumental 时,主艺术家为对应非 Instrumental 版本的 VocalComposer。特别地,当该曲目对应非 Instrumental 曲目为某曲目的 Rearrange 时,主艺术家为对应非 Instrumental 版本的 Vocal 或该曲目的 Arranger
  3. 当该曲目的类型为 Absolute 时,主艺术家为 Composer。特别地,当该曲目为某曲目的 Remix/Rearrange 时,主艺术家为 Arranger

示例如下:

  1. 《Track A》为 Artist A 个人单曲中的某一音轨,曲目类型为 Normal。此时主艺术家为 Vocal,即 Artist A
  2. 《Track B》为 Artist A 个人单曲中的某一音轨,曲目类型为 Instrumental。此时主艺术家可以为 Vocal(即 Artist A),或作曲(Composer)。
  3. 《Track C》为 Artist A 个人单曲中的某一音轨,曲目类型为 Instrumental,且该音轨为 《Track B》 的 Rearrange。此时主艺术家可以为 Vocal(即 Artist A),或《Track C》的 Arranger
  4. 《Track D》为 Artist A 个人单曲中的某一音轨,曲目类型为 Absolute。此时主艺术家为该曲目的作曲(Composer)。
  5. 《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

对于标签的使用方式,作如下约定:

  1. 对单曲碟片中的曲目,同一曲目在其各版本收录单曲中标签应保持一致。
  2. 对专辑碟片中的曲目,如同一曲目在此前发售的单曲碟片中已经存在,则专辑中不需要包括该标签。

音乐类型

对明显有歌、曲的音轨,定义其类型为 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.jsonrepo.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,用户也可能会选取多个元数据仓库作为数据来源。因此无论是在整理侧还是用户侧,对多个元数据仓库实现合并都是必要的。

专辑合并

专辑的第一去重标准是品番。当不存在品番冲突时,两个元数据仓库可以无缝合并。而当存在品番冲突时,则需要进行合并操作。下文规范了这种合并操作的具体流程。

专辑相似度

两张品番相同,且相似度极大的专辑很有可能就是同一张。对专辑相似度的判定基于以下基准:

  1. 专辑品番一致
  2. 专辑发售日期一致
  3. 专辑中碟片数量相同
  4. 专辑中每张碟片中的音轨数量相同

一般而言,上述四条完全相同就可以判定两张专辑为同一专辑。各实现可以在此基础上定义更多的规则。

合并内容

对两张重复的专辑,需要合并的内容如下:

  1. 专辑、碟片、音轨的 Tag
  2. 音轨的 artists 字段

专辑 Tag 合并

Tag 合并时,取 Tag 的并集。

artist 合并

artist 合并时,需要对其下所有字段进行合并。当存在冲突时,实现可以选择任意一边的值。

Tag 合并

不同的仓库可能会使用各自不同的 Tag,因此仓库合并时也需要对 Tag 进行合并。

合并时,按照 Tag 的名称进行合并,将两棵 Tag 树合并为一张有向 Tag 图。 合并完成后,该有向图中不能出现环路,当出现环时合并失败,需要人工介入。

音频后端

音频后端是 Anni 中与实际文件系统接触的抽象层。

Anni 而言,需要获取的资源主要分为两类:音频资源和图片资源。其中图片资源作为音频资源的附加而存在,如封面等。

根据 Anni 约定,我们有一个近似唯一的 Catalog。在很多情况下 Catalog 已经足以作为唯一标识,但实际情况下仍然会出现冲突。因此音频后端需要选取另一个更加独立的值作为专辑索引的主键。因此音频后端约定为每张专辑生成唯一的 UUID 作为 album_id,充当专辑层面的索引。

由此,我们抽象出一个简单的文件系统,其通过 album_iddisc_idtrack_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>;
}
}

其中获取返回的类型由 ResourceReaderAudioResourceReader 描述:


#![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 的团队共享盘实现音频仓库的协作整理

文件系统

简介

常规的文件系统后端,通过系统调用读取目录结构和文件。

属性

属性名类型说明
rootstring文件后端的根目录

Google Drive

简介

Google Drive 作为文件存储部分的后端,通过 Google Drive API v3Google 服务器进行通信。

认证方式

OAuth2

OAuth2 是最常规的验证方式。用户通过 OAuth 同意屏幕授予 Anni 其申请的权限,Anni 通过得到的 TokenGoogle 服务器发出请求。

通过 OAuth2 认证,我们至少需要如下基本参数:

参数说明
client_idOAuth 客户端 ID
client_secretOAuth 客户端 Secret
project_id项目的唯一标识符

服务帐号(Service Account)

Service Account 是另一种认证方式。通过增加 Service Account,我们可以实现细粒度的权限控制。

文件访问

Google Drive 中网盘大致可分为两类:个人盘和团队盘。

音频仓库与 Annil

文件后端本质只是一个抽象后的文件系统,实际的文件分发过程仍需要一个服务端程序的参与。或者更准确地说,一套协议的参与。

这套协议需要实现的功能有:

  1. 对客户端的访问进行鉴权,判断其是否有权限访问仓库内的文件
  2. 鉴权通过后,从合适的音频后端中获得文件,并将文件交付给客户端

同时,由于通常我们会拥有复数个音频后端,因此音频仓库需要在单个接口下提供对不同后端的透明访问。

Project Anni 中,实现了这一系列操作的就是 AnnilAnni Library)。

Anni 音频仓库协议

版本信息

⚠️️ 在协议版本迈入 1.0 之前,任何改动都有可能发生。

最后修改:2022 年 05 月 10 日 协议版本:0.5.0

定义

Anni 音频仓库(Anni Audio Library,以下简称音频仓库)是指实现了 Anni 音频仓库协议中定义 API 的服务端应用程序。其核心功能有三:

  1. 对用户身份的鉴定
  2. 对分享的权限认证及限制
  3. 向用户分发资源(包括音频和封面)

跨域约定

Anni 音频仓库协议约定所有请求的返回中都包含头部:

Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Allow-Headers: Authorization

建议带有:

Access-Control-Allow-Origin: *

各实现可以允许用户选择能够进行跨域请求的域名。

返回状态码

Anni 音频仓库协议使用了以下 HTTP 状态码。

状态码含义
400请求非法
200请求成功
403Token 校验失败或权限不足
404资源不存在

鉴权

对请求者的身份鉴定通过 JWTJSON 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 夹带在 URLquery 中。仅对 GET 请求可用。

用户身份鉴定

以下为用户 TokenUser 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

用户 TokenClaim 部分定义了 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 约定中的各专辑均带有封面信息,因此资源分发同时支持音频和封面。

音频分发

音频分发基于音频所在 Discalbum_iddisc_idtrack_id。用户获取音频的前提是其提供的 Token 允许其访问对应的资源。

获得音频的 API 定义为 GET /{album_id}/{disc_id}/{track_id}。通过 HEAD /{album_id}/{disc_id}/{track_id} 可以只获取 Header 信息。

音频偏好

考虑到客户端的不同网络场景和带宽因素,以下的参数可以向音频仓库表明客户端的格式偏好。

参数名说明可选项
quality客户端期望的音频质量。lowmediumhighlossless

需要注意的是,客户端传递的参数仅为客户端偏好,对音频仓库仅有指导意义,不代表实际结果。

音频仓库在对参数进行处理时,应以枚举而非字符串拼接的形式进行,以防止命令注入。

请求范围

为了达到更好的播放效果,Annil 实现可以选择实现 HTTP 请求范围

当音频仓库支持某一音频资源的单一范围请求时,HEADGET 请求的返回中必须带有 Accept-Ranges: bytes。当该头部不存在,或为 none 时,表示音频仓库不支持该音频文件的单一范围请求。

Anni 音频仓库协议暂不支持多重范围请求。

当客户端向音频仓库通过 GET 请求某一范围时,下文所述的返回头部不保证可用

返回头部

当成功获取音频时,返回的头部会携带如下额外信息:

Key含义
Content-Type音频的 MIME Type,根据情况可能为 audio/flacaudio/mp3
X-Origin-Type源文件的 MIME Type,当与 Content-Type 不同时表明音频经过了转码
X-Origin-Size源文件的大小,单位为字节(Byte
X-Duration-Seconds音频的长度,单位为秒
X-Audio-Quality音频的实际音质,可选值为 lowmediumhighlossless

封面分发

封面分发基于音频所在专辑的 album_id 和可选的 disc_id。获取封面无需 Token

获得封面的 API 定义为 GET /{album_id}/{disc_id}/coverGET /{album_id}/cover,默认 disc_id1

返回头部

当成功获取封面时,返回的头部会携带如下额外信息:

Key含义
Content-Type图片的 MIME Type,当前定义中只能是 image/jpeg

管理接口

Annil 定义了如下的管理接口,供音频仓库的管理员使用。

以下所有 API 均为可选,对接时须确认各接口是否可用,以及公网可访问性等细节。接口仅包含最小定义,在满足下文定义行为的前提下,各实现可以自行添加部分参数以实现功能扩展。

访问管理接口时需要包含 Authorization 头部,该头部的获取方式不在本文规定范围之内。

管理接口不应允许跨域访问。

签署用户 Token

签署用户 TokenAPI 定义为 POST /admin/sign

{
  "user_id": "rua",
  "share": true
}

返回结果为签名后的用户 Token

资源热更新

热更新 Annil 中各个 Provider 中资源的 API 定义为 POST /admin/reload

热更新的过程是同步的,更新结束后请求才会返回。

不同的音频仓库实现

Annil 本身只是音频仓库实现的标准。我们鼓励不同的音频仓库实现。

实现列表

名称地址
annil-rshttps://github.com/ProjectAnni/anni/tree/master/annil
go-annilhttps://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:DNSanni.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 Okbody 均为 JSON 格式,结构如下:

interface ResponseBody<T> {
  status: number;
  message?: string;
  data: T;
}

在后续的描述中,我们只描述类型 T 的结果,省略外层包裹的 JSON Object

status0 时,data 中包含类型为 T 的返回数据。

返回状态

返回状态为 6 位数字,具体划分如下:

领域分类编号
1 代表基本错误代码,2 代表特性错误代码,9 代表通用错误代码2 位数字3 位数字

无错误

无错误时,返回的 status0

通用错误

通用错误如下表所示:

错误代码详情
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_idstring用户 ID
emailstring邮箱
nicknamestring昵称
avatarstring头像链接

参数表示

interface UserInfo {
  user_id: string;
  email: string;
  nickname: string;
  avatar: string;
}

修改密码

Endpoint

PATCH /api/user/password

请求参数

参数名类型详情
old_passwordstring原密码
new_passwordstring新密码

返回

原密码正确时,将用户密码修改为新密码。

错误列表

错误代码详情
102010原密码错误

用户简介

Endpoint

GET /api/user/intro

请求

请求参数

参数名类型详情
user_idstring用户 ID

返回

返回指定用户的用户简介(UserIntro)。

返回参数

参数名类型详情
user_idstring用户 ID
nicknamestring昵称
avatarstring头像链接

参数表示

interface UserIntro {
  user_id: string;
  nickname: string;
  avatar: string;
}

错误列表

错误代码详情
102020用户不存在

修改简介

Endpoint

PATCH /api/user/intro

请求参数

参数名类型详情
nicknamestring昵称
avatarstring头像

返回

当用户已登录时,修改用户昵称与头像。

错误列表

错误代码详情
102000昵称不可用

用户注册

该接口无需用户登录。

Endpoint

POST /api/user/register

请求

请求参数

参数名类型详情
passwordstring密码
emailstring邮箱
nicknamestring昵称
avatarstring头像链接

返回

注册成功时,返回用户信息(UserInfo)。

参数表示

interface UserRegisterBody {
  email: string;
  password: string;
  nickname: string;
  avatar: string;
}

错误列表

错误代码详情
102000注册昵称不可用
102001注册邮箱不可用
102002注册密码格式错误

用户登录

该接口无需用户登录。

Endpoint

POST /api/user/login

请求

请求参数

参数类型详情
emailstring邮箱
passwordstring密码

返回

登录成功时,返回用户信息(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

请求

请求参数

参数类型详情
emailstring邮箱

错误列表

错误代码详情
102001注册邮箱不可用

播放列表

播放列表是顺序的歌曲列表,用户可以通过播放列表对零散的歌曲进行整理。

播放列表相关的功能有获取、创建、修改、删除。

错误一览

错误代码详情
103000用户播放列表数已达上限
103001播放列表中歌曲数量已达上限
103002播放列表不存在
103003修改类型非法
103003歌曲 ID 非法

获取播放列表

获取指定播放列表

Endpoint

GET /api/playlist

请求

请求参数
参数名类型详情
idstring播放列表标识符

返回

请求成功时,返回播放列表的全部信息。

返回参数
参数名类型详情
idstring播放列表标识符
namestring播放列表名称
descriptionstring播放列表说明
ownerstring播放列表拥有者 ID
is_publicboolean是否公开
items(PlaylistItem & Id)[]播放列表中的内容
coverPlaylistCover播放列表封面,为 null 时客户端应使用该 Playlist 的第一个 Track 封面
last_modifiednumber播放列表最后修改时间戳(秒)

PlaylistItem 可以为 PlaylistItemTrackPlaylistItemDummyTrackPlaylistItemAlbum,分别表示歌曲、占位歌曲和专辑,详见下方类型定义。

每种 PlaylistItem 类型均含有:

参数名类型详情
descriptionstring对歌曲的说明文本 可为空
infoT该类型的附加数据

PlaylistItem & Id 类型在各 PlaylistItem 的基础上增加了:

参数名类型详情
idstring歌曲在歌单中的 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_idstring用户 ID,缺省为当前用户

返回

若指定用户为自己,则返回所有播放列表。否则仅返回公开的播放列表。

返回 PlaylistInfo[]

创建播放列表

Endpoint

PUT /api/playlist

请求

请求参数

参数名类型详情
namestring播放列表名称
descriptionstring播放列表说明
is_publicboolean是否公开
coverPlaylistCover播放列表封面
itemsPlaylistPatchItem[]初始加入播放列表的内容

返回

处理完成后,返回新创建的播放列表信息(Playlist)。

参数表示

interface CreatePlaylistBody extends Omit<PlaylistInfo, "id" | "owner"> {
  items: PlaylistPatchItem[];
}

错误列表

错误代码详情
103000用户播放列表数已达上限
103001播放列表中歌曲数量已达上限

修改播放列表

Endpoint

PATCH /api/playlist

请求

对播放列表的修改分为三个部分:增加歌曲、删除歌曲和排列歌曲。

请求参数

参数名类型详情
idstring增加歌曲的播放列表
commandstring表示修改的类型,可选项为 infoappendremovereorderreplace
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

请求

请求参数

参数名类型详情
idstring播放列表标识符

错误列表

错误代码详情
103002播放列表不存在
103003非法修改类型

Annil Token 管理

考虑到多客户端的使用场景,Anniv 实现了一套对 Annil Token 的管理系统,供客户端之间同步 Token 信息。

错误一览

错误代码详情
104000用户 Token 存储数已达上限
104001Token 不存在

获取 Token

Endpoint

GET /api/credential

返回

返回包含所有请求 Token & Id & Controlled 的数组。

返回参数

Token 的定义如下所示:

参数名类型详情
namestringAnnil 名称
urlstringAnnil 站点地址
tokenstringAnnil Token 内容
prioritynumber客户端尝试访问时的优先级

Controlled 的定义如下所示:

参数名类型详情
controlledbooleanToken 是否为受控 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

错误列表

错误代码详情
104001Token 不存在

删除 Token

Endpoint

DELETE /api/credential

请求

请求参数

参数名类型详情
idstring待删除 TokenID

错误列表

错误代码详情
104001Token 不存在
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

参数

参数名类型说明
tagstringTag 名
recursiveboolean是否递归检索子 Tag 所包含专辑

返回

AlbumDetail[]

错误列表

错误代码详情
902000Tag 不存在

获取 Tag 关系

GET /api/meta/tag-graph

返回

返回 tag 依赖关系的邻接表,类型表示为 Record<string, string[]>

Key-Value 对应的是标签与其所包含的子标签。字符串格式为 <EDITION>:<NAME>,标签类型不可省略。

搜索

Endpoint

GET /api/search

请求参数

参数名类型说明
search_albumsbool是否搜索专辑
search_tracksbool是否搜索音轨
search_playlistsbool是否搜索播放列表
keywordstring搜索关键字

返回

返回 SearchResult

参数列表

interface SerachResult {
  albums?: AlbumDetail[];
  tracks?: TrackInfoWithAlbum[];
  playlists?: PlaylistInfo[];
}

分享管理

Anniv 基于信息导出格式实现单曲、专辑和播放列表的分享。

错误一览

错误代码详情
105000用户分享数已达上限
105001分享链接不存在

获取分享链接内容

该接口无需用户登录。

Endpoint

GET /api/share

请求

请求参数

参数名类型详情
idstring分享链接的 ID

返回

返回分享链接的实际内容,结构为 ExportedPlaylist

错误列表

错误代码详情
105001分享链接不存在

获取分享链接列表

Endpoint

GET /api/share/

返回

返回当前用户创建的所有分享链接列表。

返回参数

参数名类型详情
idstring分享链接的 ID
datetimestamp分享创建日期

创建分享链接

创建分享链接时,客户端需要提供待分享资源的元数据和供被分享人播放音乐的分享 TokenAnniv 负责记录用户提供的上述信息,并存储一份用户指定播放列表的快照。

Endpoint

POST /api/share

请求

请求参数

参数名类型详情
infoExportedPlaylistInfo分享的必要信息
metadataExportedPlaylistMetadata.metadata可选的元数据。当未指定时,元数据由 Anniv 提供
albumsRecord<AlbumIdentifier, Id['id']>待签发专辑与 Annil Token 的对应关系

返回

请求成功时,返回分享链接的短部。

错误列表

错误代码详情
105000用户分享数已达上限

删除分享链接

Endpoint

DELETE /api/share

请求

请求参数

参数名类型详情
idstring待删除分享链接的 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

返回

若请求成功,返回对应曲目的播放统计。

返回参数

参数名类型详情
countnumber请求曲目的播放次数

获取当前用户播放历史记录

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统计区间结束时间戳(秒),默认为请求提交时间

返回

若请求成功,返回对应曲目的播放次数。

返回参数

参数名类型详情
countnumber请求曲目的播放次数

喜欢

用户喜欢的单曲列表与播放列表。

单曲

获取喜欢列表

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_idAlbumIdentifier待添加专辑的 AlbumIdentifier

删除收藏专辑

DELETE /api/favorite/album

参数名类型详情
album_idAlbumIdentifier待添加专辑的 AlbumIdentifier

订阅播放列表

获取全部已订阅的播放列表

GET /api/favorite/playlist

返回参数

interface FavoriteListInfo {
  playlist_id: string;
  name: string;
  owner: string;
}

返回 FavoriteListInfo[] ,按照添加时间倒序排序。

订阅播放列表

PUT /api/favorite/playlist

请求参数

参数名类型说明
playlist_idstring需要订阅的列表

错误列表

错误代码详情
902004列表已订阅

取消订阅

DELETE /api/favorite/playlist

请求参数

参数名类型说明
playlist_idstring需要取消订阅的列表

歌词

Anniv 歌词 API 提供了灵活的音乐歌词管理策略。

歌词分发

GET /api/lyric

请求参数

参数名类型说明
album_idstring请求歌词的专辑 ID
disc_idnumber请求歌词的碟片 ID
track_idnumber请求歌词的单曲 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_idstring请求歌词的专辑 ID
disc_idnumber请求歌词的碟片 ID
track_idnumber请求歌词的单曲 ID
type"text" | "lrc"修改后歌词的类型
langLanguageCode修改歌词的语言
sourcestring歌词来源
datastring修改后的歌词

错误代码

错误代码详情
901000歌词修改无法写入,可能的原因有:修改 lrc 歌词为 text 歌词

特性

为了控制一些可选功能的启用与否,Anniv 定义了一系列特性。特性的启用与否由 Anniv 的管理员决定,开启后会增加一些处理步骤。

适用范围

特性的适用范围描述了特性实现的具体部分,以描述其在客户端实现中应处的具体位置。

目前有效的适用范围有:

  • 用户登录
  • 用户注册
  • 用户注销
  • ……

参数列表

当特性对原有功能做加法时,通常会增加一定的输入参数。参数列表指定了这些参数的名称和类型。

错误列表

当某一特性开启时,往往会增加特性相关的特定错误。错误列表指定了错误代码和其代表的详细错误内容。

接口列表

某些特性可能会定义其独有的接口。接口列表负责描述这些接口的请求参数和返回格式。

邀请(invite)

适用范围

  • 用户注册

请求参数

参数名类型详情
invite_codestring邀请码

错误列表

错误代码详情
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_secretstring两步验证的 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_secretstring两步验证的 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

AnnisonicProject 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

DSubAndroid 平台下的一款 Subsonic 播放器。

配置

  • Browse By Tags: Off
  • Sync Enabled: Off
  • Authorization Basic headers: Off

存在问题

  1. 缓存时会直接从 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

运行

annisonicconfig 放置于同一目录,然后通过以下命令启动即可:

RUST_LOG=info ./annisonic

Anni 命令行工具

Anni 命令行工具的设计初衷是通过自己实现的一整套工具,替代之前使用的包括 shnsplitmetaflaccuetools 等分散的工具,并解决一些其不能或未涵盖的问题,如编码处理。

在下文中,我们统一将 Anni 命令行工具称为 anni-cli

功能列表

anni-cli 目前的功能分为以下五个部分:

  • flac:针对 flac 音频格式的工具集
  • split:对整轨音频分割的工具,用于替代 shnsplit
  • convention:对 anni 音频约定进行检查的工具
  • repo:对 Anni 元数据仓库进行操作的工具

flac

针对日常使用的场景,anniflac 部分(需要)实现的功能列表如下:

  • 输出信息
    • 输出块信息
    • 输出元数据信息
      • 以纯文本格式输出
      • JSON 格式输出
      • 支持同时输出多个文件的元数据信息
    • 输出图片块中数据(导出封面)
  • 修改元数据信息
    • 修改 vendor
    • 增加元数据
    • 删除元数据
    • 修改元数据
    • 删除指定块
      • 删除元数据块
      • 删除图片块
    • 增加指定块
      • 导入封面

split

以音轨文件(如 cue)和整轨音频文件(如 wav)作为输入,分轨音频作为输出的音频分割工具。

支持格式

格式依赖工具输入输出
wav-
flacflac
apemac
taktak

功能列表

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 的文件

工作空间

音频整理工作空间看起来有点拗口的样子,之后我们就用工作空间来代替好了

工作空间的由来是为了替代掉原有的,没有任何约束的任意目录。工作空间的存在目的有两个:

  1. 通过固定的目录结构,防止误操作的出现。
    常见的误操作包括在保种的目录应用元数据、在保种的目录修改音频内嵌封面等修改原文件内容的行为。全是血泪史
  2. 通过一定的机制,简化音频整理的操作。
    在规定了目录结构后,音频目录就可以和元数据文件关联起来。元数据文件的品番在整理过程中可能(由于填写错误)存在变更,但 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

该目录中保存了音频整理所需的两大元素:音频资源和元数据仓库。目前根目录下定义的内容如下:

  1. 音频资源,以严格目录格式存放于 objects 目录下,层级为 2。当专辑目录下存在 .publish 文件时,该专辑的状态为已发布
  2. 元数据仓库,位于 repo 目录。
  3. config.toml,负责记录一些额外的配置信息。

用户目录

工作空间中除工作目录的部分都是用户目录。用户目录结构基本满足约定目录结构,但存在以下区别:

  1. 专辑目录中碟片数量的限定([n Discs])将不起作用
  2. 碟片目录的名称没有任何限制

示例

一个典型的用户目录结构如下:

├── [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 部分。用户部分有以下几个特征:

  1. 专辑目录可以位于任意嵌套层级下。
  2. 在整理过程中,目录中所有文件都是普通文件。
    [220817][VVXX-01253] 誰彼スクランブル/あれこれドラスティック【Complete Edition】
  3. 在文件基本已经确定后,可以通过 workspace add 将专辑状态更新为已跟踪。此时,所有文件都会被转移到工作目录中,原目录下仅保留符号链接。
    [210519][VVXX-01011] キミがいたしるし
  4. 专辑的目录下都存在名为 .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

修复一些常见的工作空间问题。

  1. -d, --fix-dangling:修复状态为悬垂的专辑目录
  2. --gc:清除状态为待回收的专辑目录

serve

启动工作空间的服务端环境,包括:

  • 一个基于严格目录格式的 Annil 服务端
  • 一个基于 GraphQL 的元数据服务端
  • 一个基于 WebSocket 的终端转发服务