Repository navigation
Releases: AMDL-Web/apple-music-downloader
Release list
Release v2.0.0-rc.5
v2.0.0-rc.5
2.0.0 的第五个候选版本。它是 GitHub 预发布:安装脚本默认不装它,Docker 镜像的 latest 和 2.0 标签也不会指向它。想试的话,先备份数据库,读完 升级到 2.0,别直接升级正在用的安装。
装命令行和服务端:curl -fsSL https://raw.githubusercontent.com/AMDL-Web/apple-music-downloader/dev/deploy/install/install.sh | sh -s -- --version v2.0.0-rc.5
相对 rc.4 没有破坏性变更。
升级前看一眼
- 第一次启动会跑两个新迁移,现有数据全部保留:
job_items.download_bytes(现有行为 0),以及jobs.queue_order(按创建顺序给现有任务编号,排队任务保持原来的先后)。 - 迁移过的数据库,rc.4 及更早的版本打不开(桌面端本机模式的数据库也一样)。要退回旧版,只能用升级前备份的
amdl.db。如果在这一版里新建或改过 webhook,hooks.yaml也要换回备份,或者删掉其中的preset、method、body键,否则旧版服务端不认识这些键,起不来。 - 从源码克隆运行、并且在仓库里的
configs/hooks.yaml写过 hooks 的:这个文件改名为模板configs/hooks.example.yaml,configs/hooks.yaml不再入库。拉取前先把文件拷走,git checkout -- configs/hooks.yaml,拉取后再拷回。Docker 卷里的hooks.yaml不受影响。
服务端
- 下载队列可以调整运行顺序:新增
POST /api/v1/downloads/{job_id}/move,任务已不在排队时返回 409job_not_queued。排队任务按保存下来的顺序开始,新任务和重试排在最后,重启时被中断的任务排在最前。排队中的任务带queue_order,GET /api/v1/downloads?sort=queue_order按运行顺序列出,每次移动记一条job_moved事件。 - 解密密钥改为在音轨下载的同时预取,不再在解密阶段干等 wrapper 逐个返回。带宽不是瓶颈时整张专辑明显更快:一张 12 首 ALAC 的专辑从约 51–55 秒降到约 39–41 秒;受带宽限制的专辑变化不大。等密钥时状态显示为
waiting_decrypt。 - hooks 可以通过 API 编辑:总开关,新建、修改、删除 webhook,发送测试。写入时就地改写
hooks.yaml,注释和其他条目保留,所以它所在的目录要可写。命令类 hook 只读,仍需在hooks.yaml里改。 - webhook 新增预设:ntfy、Bark、Telegram 通知,Plex、Jellyfin、Emby 刷新媒体库,以及自定义请求。手写的 webhook 也能用新键
method(GET、POST、PUT)和body(基于 payload 的模板);payload 的job新增title和artist_name。 - 搜索支持歌单,结果里新增 playlists 分组。
- 每个条目报告已下载的字节数
download_bytes,下载途中就能算平均速度。 - 修复:低内存模式下同一任务的音轨互相清掉断点目录,导致多一次下载重试。
- 修复:音轨从保存进入打标签时进度倒退,速度和剩余时间估算因此重置。
桌面端
- 队列可以拖动排序,也可以用 Alt(macOS 为 Option)加方向键,或任务菜单里的 Run next、Run last。
- 搜索加入歌单,可按分组翻页。首页去掉了音质和覆盖开关:从搜索或专辑页下载时,只在真有选择时问一次(无损还是杜比全景声,跳过还是替换已有文件);粘贴链接直接按服务器设置下载。设置里打开 Skip files already downloaded 就总是跳过。
- 把 Apple Music 链接拖进窗口任意位置即可下载。
- 设置里可以开关和编辑 webhook,从预设新建时只填几项值。
- 设置合并为一整页连续滚动;编解码器优先级去掉了箭头,改为拖进或拖出 Not used 一栏。
- 专辑页和任务详情页统一布局;已结束的任务可以删除,已保存的文件留在磁盘上。
- 下载中就显示平均速度;剩余时间在任务后半程不再停在「-」。
- 队列默认打开 All;连接状态移到侧栏底部;托盘面板随内容变化立即贴合高度。
控制台
- Queue 可以拖动排序,也可以用 Alt+方向键、Alt+Home/End,或行菜单里的 Move to top、Move to bottom;默认打开 All。每行末尾的 … 菜单可以看详情、重试或取消、复制链接,也能单独删除已结束的任务(文件保留在磁盘上)。
- 下载前弹窗询问音质和已有文件的处理方式,取代原来的 Overwrite 开关。
- 搜索支持歌单,Show more 单看一个分组并随滚动翻页。
- Settings › Hooks 可以编辑 webhook,添加时可选预设。
- 编解码器优先级改为在 In use 和 Not used 之间拖动。
- 任务抽屉显示平均速度和剩余时间;排队任务不再显示 Resolving;进度数字变化时不再抖动。
终端界面、CLI 和 MCP
- 终端界面重做:开场横幅显示服务器、wrapper 和磁盘状态,任务有 #1、#2 编号;新增
/queue、/quality、/copy,支持NO_COLOR。 - 终端界面也有平均速度、下载前的选择和分页搜索。
- CLI 新增
jobs move、hooks presets、hooks switch、hooks set、hooks test、hooks delete;MCP 新增move_job;搜索都支持歌单。
这个候选版本不含的
- 桌面安装包仍未签名,每个系统第一次打开时会拦一次,怎么放行见文末的「Desktop app」一节和 安装文档。
- Homebrew 和 Scoop 从 2.0.1 开始发布;这一版的 Release 附件里仍有渲染好的
amdl.rb和amdl.json。
致谢
Desktop app
amdl-desktop-* are the desktop app for macOS (.dmg, Apple silicon arm64 or Intel amd64), Windows (-setup-*.exe) and Linux (.deb or .tar.gz). They are not signed yet, so each system asks once:
- macOS: drag AMDL to Applications and open it. When macOS says it cannot check the app, open System Settings › Privacy & Security and choose Open Anyway, or run
xattr -dr com.apple.quarantine /Applications/AMDL.app. - Windows: when SmartScreen stops the installer, choose More info › Run anyway.
docs/install.md has the details.
Release v2.0.0-rc.4
v2.0.0-rc.4
2.0.0 的第四个候选版本。这是第一个附带桌面端安装包的候选版本,也就是说 2.0 会和桌面端一起发布。它是 GitHub 预发布:安装脚本默认不装它,Docker 镜像的 latest 和 2.0 标签也不会指向它。想试的话,先备份数据库,读完 升级到 2.0,别直接升级正在用的安装。
装命令行和服务端:curl -fsSL https://raw.githubusercontent.com/AMDL-Web/apple-music-downloader/dev/deploy/install/install.sh | sh -s -- --version v2.0.0-rc.4
相对 rc.3 没有破坏性变更。
桌面端
- Release 附件里的
amdl-desktop-*就是桌面端,覆盖 macOS(Apple 芯片和 Intel 各一个.dmg)、Windows(安装程序)和 Linux(.deb和.tar.gz)。 - 第一次打开时选择下载在哪里跑:局域网里的一台服务器,或者这台电脑本身。选这台电脑的话,设置页会检查并安装需要的 ffmpeg 和 wrapper。安装包本身不带 ffmpeg 和 QEMU。
- 安装包暂时没有签名,每个系统第一次打开时会拦一次。怎么放行见文末的「Desktop app」一节和 安装文档。
其他改动
- 控制台在手机上的底栏改成五格:Overview、Queue、新建下载、Settings、Logs,新建下载的加号在正中间。Logs 单独占一格,不用再从 Settings 进。
这个候选版本不含的
- Homebrew 和 Scoop 从 2.0.1 开始发布;这一版的 Release 附件里仍有渲染好的
amdl.rb和amdl.json。
致谢
Desktop app
amdl-desktop-* are the desktop app for macOS (.dmg, Apple silicon arm64 or Intel amd64), Windows (-setup-*.exe) and Linux (.deb or .tar.gz). They are not signed yet, so each system asks once:
- macOS: drag AMDL to Applications and open it. When macOS says it cannot check the app, open System Settings › Privacy & Security and choose Open Anyway, or run
xattr -dr com.apple.quarantine /Applications/AMDL.app. - Windows: when SmartScreen stops the installer, choose More info › Run anyway.
docs/install.md has the details.
Release v2.0.0-rc.3
v2.0.0-rc.3
2.0.0 的第三个候选版本。rc.2 之后的主要变化:重复下载已有的专辑不再卡住,wrapper 的 media-user-token 由后端自己读取,新增查询专辑曲目的接口。它是 GitHub 预发布:安装脚本默认不装它,Docker 镜像的 latest 和 2.0 标签也不会指向它。想试的话,先备份数据库,读完 升级到 2.0,别直接升级正在用的安装。
装这一版:curl -fsSL https://raw.githubusercontent.com/AMDL-Web/apple-music-downloader/dev/deploy/install/install.sh | sh -s -- --version v2.0.0-rc.3
相对 rc.2 没有破坏性变更。
新功能
- 后端直接读 wrapper 自己维护的 media-user-token(
MUSIC_TOKEN文件),每次用时现读,读不到才用catalog.media_user_token。用amdl wrapper install装在本机的 wrapper 自动读取;容器部署用新配置键wrapper.data_dir或环境变量AMDL_WRAPPER_DATA_DIR指向挂载的 wrapper 数据目录。目前只支持一个 wrapper 账号,wrapper-manager 也只读一个。 GET /api/v1/albums/{album_id}:返回专辑信息和按碟号、曲号排好的曲目。命令行是amdl albums get,MCP 是get_album。
主要修复
- 重复下载已经下过的专辑:已在磁盘上的曲目先判定跳过,不再去取封面和歌词。同时把发往 wrapper 的并发连接限制在 wrapper-lite 能同时处理的数量以内,原来一次开十几个连接,部分曲目会卡约 30 秒才超时重试。实测 12 首的专辑重复提交,3 秒内全部跳过。
- 进度条各阶段的权重按实测耗时重新分配:下载约占 77%,解密约 9%,原来下载完成时进度条才走到 60%。
- 本机 wrapper 的系统镜像改为多连接分段下载,支持断点续传,下载完校验 sha256。原来单连接只有几十 KB/s。
- 控制台:任务进度更易读,替换草稿后可以撤销,任务完成时弹出提示;清掉一批没再用到的样式。
这个候选版本不含的
- 桌面端会和 2.0.0 一起发布,这个候选版本还没有附带它的安装包。
- Homebrew 和 Scoop 从 2.0.1 开始发布;这一版的 Release 附件里仍有渲染好的
amdl.rb和amdl.json。
致谢
Release v2.0.0-rc.2
v2.0.0-rc.2
2.0.0 的第二个候选版本。rc.1 之后,新装的 Mac 用户、每天用控制台的人、用命令行和 MCP 的 agent 三种用法各做了三轮真实使用,这一版带上了由此修好的问题。它是 GitHub 预发布:安装脚本默认不装它,Docker 镜像的 latest 和 2.0 标签也不会指向它。想试的话,先备份数据库,读完 升级到 2.0,别直接升级正在用的安装。
装这一版:curl -fsSL https://raw.githubusercontent.com/AMDL-Web/apple-music-downloader/dev/deploy/install/install.sh | sh -s -- --version v2.0.0-rc.2
相对 rc.1 的破坏性变更
升级指南里各有一行。
- Apple 查无此 id 时,
POST /lookup、GET /search、GET /artists/{artist_id}/albums和POST /quality返回404 catalog_not_found,原来是502 upstream_error;命令行退出码为 3。 GET /config、PUT /config和POST /config/reload只返回catalog.media_user_token的末 4 位;PUT收到这种打码值时保留已存的 token。GET /wrapper/status的status改名为logged_in。- 取消已经
completed或failed的任务返回409 job_not_cancellable,命令行退出码为 4。 - 命令行:
--wait和jobs wait在任务结束时就返回,hook 还在跑时结果里带hooks_running,已结束的任务不再报超时;任务摘要的album_progress只留total、finished、percent。 - MCP:没有服务端也能启动,参数校验和错误码与命令行一致。
主要修复
- 只有 AAC 的专辑查音质不再报 502,控制台写明会回退到 AAC-LC。
amdl download --watch在任务结束后约 0.05 秒返回,原来要多等 6 到 9 秒。- 控制台:列表和 Overview 显示任务的实际进度(任务新增
progress字段);链接不存在的失败任务不再提供 Retry;从书签带进来的链接不再并进上次的草稿。 - wrapper:各处提示统一说明怎么查看和修改 wrapper 地址;
wrapper install、update、uninstall支持--yes;非终端下的wrapper login提示改用--password-stdin。 amdl cli setup给出的片段在连远程服务时总带上--server;配置键拼错时给出最接近的键。- 安装脚本下载失败时说清原因;版本号不带
v也能装。 - 帮助文本只用 ASCII,Windows 旧代码页的控制台不再乱码。
这个候选版本不含的
- 桌面端不在 2.0.0 里,签名账号到位后单独发布。
- Homebrew 和 Scoop 从 2.0.1 开始发布;这一版的 Release 附件里仍有渲染好的
amdl.rb和amdl.json。
致谢
Release v1.7.2
Release v1.7.1
v1.7.1
维护版本:接上 Renovate 自动升级依赖,并把它开出的第一批升级合了进来。没有功能变更,没有 API 变更,没有数据库迁移。
之所以发一个补丁号而不是把这些留在 dev:镜像会变(alpine 3.22 → 3.24、golang 1.25.12 → 1.26.5),链进二进制的依赖也会变。这两样都是发出去才算数的东西。
依赖升级
| 从 | 到 | |
|---|---|---|
github.com/Eyevinn/mp4ff |
v0.48.0 | v0.55.0 |
google.golang.org/grpc |
v1.82.1 | v1.83.0 |
modernc.org/sqlite |
v1.53.0 | v1.55.0 |
golang.org/x/time |
v0.14.0 | v0.15.0 |
alpine(运行镜像) |
3.22 | 3.24 |
golang(构建镜像) |
1.25.12-alpine | 1.26.5-alpine |
go.mod 的 go 指令 |
1.25.12 | 1.26.5 |
actions/checkout、actions/setup-go |
v6 | v7 |
Action 那一条顺带修了个不一致:actions/checkout 此前在 ci.yml 等文件里是 v6、在 release.yml 和 weekly-snapshot.yml 里是 v7,现在全仓库统一。
go.mod 的 Go 版本必须跟着构建镜像走
这两行此前精确相等,都是 1.25.12。Renovate 只认识 Dockerfile 里的 golang 镜像,把它升到 1.26.5-alpine 之后,go.mod 仍然写着 go 1.25.12——而 CI 的 actions/setup-go 用的是 go-version-file: go.mod。
结果是发出去的二进制由 Go 1.26.5 编译,CI 却只测过 1.25.12。Go 向后兼容,构建不会失败,所以这个偏差不会以任何形式报错,只是 CI 从此覆盖不到实际出货的那套 toolchain。
go.mod 的 go 指令因此一并升到 1.26.5,两边重新对齐,CI 测的就是出货的。这一行上方加了注释说明它和 Dockerfile 的绑定关系。整套 CI 检查(gofmt、build、vet、test、race、govulncheck)都在 1.26.5 下重跑过。
这也意味着从源码构建本项目现在需要 Go 1.26.5 或更高。
mp4ff v0.55 改了加密 API,影响只落在测试里
InitProtectData.ProtFunc 变成非导出字段,EncryptFragment 的签名从只返回 error 变成返回 ([]byte, error)。生产解密路径走的是 DecryptFragment,签名没动——所以 CI 里 go build 一路是过的,挂掉的是把测试文件也纳入编译的 go vet。
internal/media/mp4_codec_roundtrip_test.go 两处适配:
- ALAC 夹具改用
ExtractInitProtectData,从它自己手工拼好的那个受保护 init segment 里把数据读回来。这里不能改用InitProtect:它会在夹具刚写好的 sinf/tenc 上再叠一层,而这正是当初要用结构体字面量构造的原因。 EncryptFragment返回的 IV 现在链式传给下一个分片,不再让每个分片都从同一个基准 IV 重新开始——那等于 AES-CTR 密钥流复用,新签名的存在就是为了避免它。断言不受影响:解密自始至终用的是从各个senc盒子里抓的每样本 IV,从来不是基准 IV。
验证是带 ffmpeg 真跑的,夹具真造、真解密。alac 与 alac-multifrag 都过,而多分片正是 IV 链式传递唯一会改变行为的场景。
Renovate
配置在 .github/renovate.json5,覆盖 gomod、Dockerfile 基础镜像、workflow action 三个生态。每周一 09:00(Asia/Shanghai)扫一次,PR 一律开向 dev,跟着下一次发版进 main,不绕过发版流程。
三个坑写在配置注释里,这里再记一遍:
- 配置只从默认分支读。 官方原话是 "Renovate always uses the config from the repository's default branch, even if that configuration specifies
baseBranchPatterns"。所以这份文件必须待在main上,尽管它开的 PR 全部指向dev。 config:recommended会把 Go 依赖标成fix(deps)。 它带了:semanticPrefixFixDepsChoreOthers,而.github/scripts/generate-changelog.js按前缀分类,fix归进「修复 | Fix」。依赖升级不是 bug 修复,所以packageRules里把类型显式钉成chore,actions 组钉成ci。minimumReleaseAge: "3 days"。 新版本发布满三天才升,躲开当天就被撤回的版本,也给投毒的包留出被发现的时间。
托管版还有一个开关不在仓库里:Mend 组织设置的 Silent 模式。开着的时候 Renovate 照常扫描算结果,但不往 GitHub 写任何东西,PR 全堆在 developer.mend.io 上等手动放行——看起来和「Renovate 没跑」完全一样。已关闭,同时打开 Require config file,把作用范围限制在确实有 Renovate 配置的仓库,避免组织里其他仓库一起收到依赖 PR。
bot 开的 PR 不再触发 AI 审查
依赖升级 PR 的 diff 全是版本号,Claude 和 Codex 读不出什么,两次审查纯烧额度。两个 workflow 的 job 级 if: 各加了一条 github.event.pull_request.user.type != 'Bot',用的是通用判断而非只匹配 renovate[bot],以后接别的 bot 自动生效。人开的 PR 不受影响。
安全网一点没动:ci.yml(build、vet、test、race、govulncheck)和 CodeQL 在 bot PR 上照常跑。上面 mp4ff 那个破坏性改动就是被它们拦下来的。
顺带修好的:DCO 必需检查一直绑错了 app
不是代码改动,但值得记一笔。dev 和 main 的分支保护都要求一个名为 DCO 的检查来自 GitHub Actions(app 15368),而 DCO 实际是 probot 的 dco app(app 1861)发出的。来源对不上,这个必需检查就永远不算满足——仓库里任何 PR 都合不了,只能靠管理员绕过。同一份规则里的 Build and test 绑 15368 是对的,错的只有 DCO 这一条。
拖到现在才暴露,是因为按仓库约定改动一直直接 push 到 dev,Renovate 这三个是史上第一批以 dev 为目标的 PR。两个分支的规则现已指向正确的 app。
- ci(renovate): add Renovate config for automated dependency updates — @LYJW131, @claude
- ci(review): skip the AI auto-reviews on bot-authored PRs — @LYJW131, @claude
- chore(deps): update docker base images — @renovate[bot]
- chore(deps): update go dependencies — @renovate[bot]
- test(media): adapt the CENC fixture builders to mp4ff v0.55 — @LYJW131, @claude
- ci(deps): update github actions to v7 — @renovate[bot]
Release v1.7.0
v1.7.0
资料库同步:后端定期检查已登录的 Apple Music 资料库,把新加进去的曲目所属的整张专辑作为普通下载任务提交。手机上收藏一首歌,NAS 那边自动把专辑下下来。
默认关闭。 这是唯一一个会自己创建下载任务的功能,升级到这个版本的安装必须显式开启。
本次没有破坏性变更。新增一张数据库表,纯追加(CREATE TABLE IF NOT EXISTS),不触碰任何现有表和数据。
新功能:资料库同步
新增配置段 library_sync,两个键,都可以在运行时改(PUT /api/v1/config、iOS 设置页):
enabled:开关,默认false。关掉是真的停止轮询,而不只是丢弃结果。interval_minutes:轮询间隔,1–1440,默认 15。改了立即生效——轮询循环按固定短节拍走、每拍判断是否到点,所以把间隔从 24 小时改成 5 分钟不会等到明天才生效。
依赖 catalog.media_user_token:个人资料库只有订阅令牌读得到,developer token 单独不行。该字段为空时监视器保持空转并说明原因,不会每轮报错刷屏。
两个新端点:
GET /api/v1/library-sync:状态(开关、间隔、锚点条目数、最近一轮结果)。功能关闭时同样返回,客户端不必猜测开关状态。POST /api/v1/library-sync/reset:清空锚点。
Apple 不给曲目的加入时间,所以水位线是位置而不是时间
这是整个功能里唯一需要解释的设计。
library-songs 没有 dateAdded 属性——extend= 和 fields[library-songs] 都要不出来。library-albums 有 dateAdded,但那是该专辑第一首歌入库的时刻,之后往同一张专辑补歌它不会变:一张 2024 年加入、今天补了一首的专辑,读出来仍然是 2024。sort=-dateModified 直接被 400 拒绝。
能用的只有一条:sort=-dateAdded 返回的顺序是准的。
所以监视器记住最新 50 首曲目的 id 当锚点,每轮倒序翻页直到重新看见锚点里最老那一首,中间凡是锚点没有的即为新增。不依赖任何时钟,不受时钟漂移影响,而且是精确判断而非近似。
首次启用只落锚、不提交任何专辑——开启那一刻库里已有的东西一律算「已有」,补下整个历史资料库从来不是这个功能的意图。
两个容易做错的地方,值得单独写出来:
- 每轮会多读 25 首,这样普通的新增一次请求就能解决。这多出来的部分按定义比所有锚点条目都老,它们不在锚点里只是因为锚点只记固定条数——所以「是否在锚点集合里」不能单独用来判断新旧,边界是锚点最老那一首的位置。把「不在锚点里」当成「是新的」,会导致每一轮都重复提交约 25 张老专辑。
- 锚点最老那首如果已经从资料库里消失,就没有可比的边界了。这时重新落锚并且不提交任何东西,而不是把扫到的几百张全部塞进队列。
开销
稳态一轮通常只有 1 次 Apple 请求(锚点窗口一页就够),每张新专辑另加 1 次——资料库 id(l.xxx)不能直接下载,要换成目录链接。按 15 分钟一轮、平时无新增算,一天约 100 次请求。
提交走的是同一条路
监视器调用 jobs.Manager.SubmitBatch,不是自己打自己的 HTTP API。所以它提交的任务和你手动提交的完全一致:同一套去重、同一套校验、同一套 hooks。目录读取用后端内部的 developer token(sign(..., nil),不带 origin claim),与 /api/v1/developer-token 签给浏览器前端的那份不同。
重置是「忘记历史」,不是「重放历史」
POST /api/v1/library-sync/reset 清空锚点之后,下一轮会以当时的资料库重新落锚,并且不提交任何专辑。适用于监视器对「已经有了」的认知与实际情况脱节时。
想重新下载某张专辑,请直接 POST /api/v1/downloads 提交它——这个接口做不到那件事,也不应该做到。
客户端
iOS 设置页新增「资料库同步」(开关 + 间隔)。旧后端不返回 library_sync 段时,App 不会把这一段发出去(后端的 PUT 会拒绝未知字段并返回 400,无条件发送会让每一次保存设置都失败),设置页那一行显示「后端不支持」并把开关置灰。
Web 前端本次未跟进:amdl-frontend/src/api/types.ts 与设置页仍无此字段。前端保存设置不受影响(PUT 是合并语义,省略即保留),只是在 Web 上看不到也改不了这个功能。
feat(librarysync): submit albums for newly added library songs — @LYJW131, @claude
Release v1.6.2
v1.6.2
补上 v1.6.1 漏发的那一半 —— 真正设置 waiting_download / waiting_decrypt 的代码;并给进度事件加上时间下限,几十首的专辑不再把事件流打满。
本次没有破坏性变更,也没有数据库迁移。
修正:v1.6.1 声明了两个永远不会出现的状态
v1.6.1 发布了枚举值和它的文档,但没有发布设置它们的代码。API 因此声明了两个不存在的状态,客户端为它们准备的分支成了死代码。行为上是失效,不是损坏。
这次把 set 调用一并发出去了。
修正:进度事件没有时间下限
此前唯一的节流是看值不看时间的:progressChanged 按整数百分比变化触发,把单个曲目的单个计量器封顶在约 101 次 —— 但对两次之间的间隔没有任何约束。一首三秒传完的曲目会在这三秒内把一百次全部打出去,再乘以并行的曲目数,而每一次都是两次 SQLite 写加一次发布。
24 首专辑、部署中的并发(下载 8 / 解密 32)实测:
| 平均事件/秒 | 计量器峰值/秒 | 每曲目进度事件 | |
|---|---|---|---|
| 之前 | 43.5 | 74 | 153.8 |
| 之后 | 19.4 | 34 | 67.6 |
progressGate 是按曲目的前沿触发闸门,作用在持久化那一步。它把旧的判断拆成两半:一次性的流水线里程碑永不延迟,两个进度计量器才受限。
它刻意不使用后台定时器。 一个在曲目终态之后才触发的定时器,会把一个已经完成的曲目在所有客户端上倒退回去。取而代之的是:闸门包住 reporter,任何非进度事件都会先把暂存的进度值冲刷出去,再发自己 —— 所以状态切换前的最后一个进度值不会丢。合并发生在写库之前而不是推送的路上,因此断线重连按 last_event_id 回放的客户端读到的历史与实时流逐字节一致。
全事件峰值几乎没变(129 → 112),因为那是启动瞬间每个曲目各自宣告前四个状态转换的爆发 —— 而那正是绝不能延迟的部分。
新增运行时配置 download.progress_event_interval_ms,默认 500,0 表示关闭时间合并,可热更新。它不提供逐任务覆盖:事件流是所有订阅者共享的,不该由某一个任务决定别人的事件率。
Release v1.6.1
v1.6.1
把「在并发池里排队」和「正在传输」拆成两个状态,客户端不再需要猜一个不动的进度条到底是慢还是堵;另外修正取消一个不存在的任务时返回 500 而不是 404。
本次没有破坏性变更,也没有数据库迁移。
新增:waiting_download 与 waiting_decrypt
一个处于 downloading 的曲目此前在做两件很不一样的事:真的在搬字节,或者在全局下载池里等号。decrypting 对解密池同理。客户端没有任何办法区分,于是一个排队中的曲目看起来就是一个卡住的传输 —— 而这恰恰是「池子饱和」被误读成「任务挂死」的方式。
现在排队的那一半有了自己的名字。两个传输态保持原义,并且只有在池子放行之后才开始。
queued → resolving → waiting_download → downloading
→ waiting_decrypt → decrypting → remuxing → tagging → saving → completed
这是对 item status 枚举的追加,不知道新值的客户端仍然看到一条合法的流水线。之所以按补丁号发:在这个枚举上做分支的只有两个手写镜像,而两边都已经跟上了 —— iOS 的 JobItemStatus 本来就有这两个 case,amdl-frontend 的 ItemStatus 与 ACTIVE_ITEM 随本次一并补齐。没有第三方客户端会因为不认识新值而把排队中的曲目判成已结束。
修正:取消不存在的任务返回 500
POST /downloads/{id}/cancel 在任务不存在时把 db.ErrJobNotFound 当成内部错误,返回 500。客户端因此分不清「这个任务已经没了」和「服务端出问题了」—— 前者该安静地把本地那一行删掉,后者该重试并告警。
现在返回 404。
Release v1.6.0
v1.6.0
新增 artist_url,让客户端能直接链到艺人页而不是把名字丢进搜索;修正去重键漏掉「目的地」这一半,导致同一内容下到不同目录被误判为重复;修掉两个并发写同一输出文件时会互相踩的暂存文件;升级 grpc 关闭一个已知漏洞。
本次没有破坏性变更,也没有数据库迁移。
新增:artist_url
想链到艺人页的客户端此前无路可走。artist_name 是展示字符串,唯一的办法是把它丢进 Apple Music 搜索 —— 落在结果页上,遇到重名还会落错人。
Apple 在解析集合时本来就会把 artist 资源一并 included 进来,所以读它的 attributes.url 不增加任何请求。这个值现在贯穿领域模型、存储与 API。
只有 artist_name 确实是艺人的类型才有这个字段 —— 单曲、专辑、艺人。歌单与电台留空,因为它们的展示名是策展人或提供方标签,不是艺人。此外在输入解析完成之前、在该字段存在之前建立的历史任务上、以及目录响应恰好没有 artist 关系的少数情况下,同样为空。客户端必须按可选字段处理,并保留原有的兜底路径。
修正:去重键漏掉了目的地
两个写向不同目录的任务本来就不是彼此的重复,但去重键没有表达这一点。canonical_key 是 type:storefront:adamID,配一条覆盖 queued/running 行的唯一部分索引 —— 于是把一张专辑下到某处、在它还没跑完时再下到另一处,会被判为重复而拒绝。而且 API 层没有任何绕过手段:请求级 force 已经不存在,overrides.force_overwrite 只管是否覆盖已有输出文件。
键上现在追加一段「规范化后的实际 downloads_dir」的哈希。
- 规范化是必须的:
<root>/./music/与<root>/music是同一个目录且后端两者都接受,直接哈希原始字符串等于让调用方换个写法就能绕过自己的去重。 - 哈希而非内嵌路径:键更短、不把文件系统路径写进索引、也不会被目录名里的
:撞坏解析。
键同时是被读回来用的:parseJobInput 从键里恢复 adam id,而不是重新解析原始输入 —— 否则当前的 album_track_url_mode 会把一条以单曲身份提交的 album?i= 链接悄悄变成整张专辑任务。该解析现在移到 jobs.ParseCanonicalKey,与构造键的代码放在一起,且采用「剥掉尾部定宽 hex 段」而不是按固定段数切分。按固定段数会在反方向引入一个新 bug:: 在 URL 路径段里是合法字符,含冒号的 adam id 今天能正确解析,固定段数切分会把它静默截断。
兼容性:三段式旧键照常解析,无需迁移。跨升级仍在队列里的任务保留旧键,不会与新提交的同目的地任务去重 —— 结果是漏一次去重,绝不会下错内容,且该窗口随这些任务进入终态而关闭。
修正:暂存文件在并发写同一输出时互相踩
copyIntoPlace 此前暂存于 dst + ".part" —— 名字由目标路径推导,而非由写入方推导。两个指向同一输出的任务因此共用一个暂存文件:互相截断、交错写入,后改名的那个会在前一个已经把共享文件改走之后撞上 ENOENT。
两个写入方想要同一个输出是常态而非奇例 —— 同一首曲目经由专辑任务与歌单任务各下一次就会发生,而且没有任何去重键会把这两者分开。processOutputLocks 在单进程内串行化了它们,但跨进程没有,且那把锁是调度层面的优化,不是文件布局的性质。
它只在 EXDEV 回退路径上可达,也就是 temp_dir 与 downloads_dir 位于不同文件系统时 —— 而这正是容器化部署的常见形态,下载根是 bind mount 而临时目录不是。
暂存名现在按调用唯一(os.CreateTemp,模式 "<name>.*.part",保留输出自身的名字,好让硬杀留下的孤儿文件能说清自己属于哪首曲目)。两个由此形态强制的细节:
- 名字会被裁剪到长度上限以内,且按 rune 边界裁 —— APFS 拒绝非法 UTF-8 的文件名。不裁的话,给一个刚好放得下的名字再加上唯一化后缀,会让下载在最后一步失败,而此时字节已经取回、解密、remux 并打完标签。
- 清理扫描改为遍历目录而不是 glob。
safeName不剥[与],一首名为Song [Live]的曲目会让filepath.Glob把方括号读成字符类,从而匹配到别的曲目的暂存文件。扫描同时覆盖新旧两种命名,因为已部署的实例可能已经存有旧格式的孤儿。
安全:grpc 升级
govulncheck 报告 GO-2026-6061 命中 google.golang.org/grpc@v1.81.1,v1.82.1 已修。它是可达的,不是死代码:调用图经 internal/wrapper/client.go(每次下载都走的解密路径)与 internal/hooks/dispatcher.go 抵达。升级后 govulncheck 报告零漏洞。
验证
gofmt、go build ./...、go vet ./...通过。- Go 测试通过:
go test -count=1 ./...(13 个包),-race在internal/media、internal/jobs、internal/db上通过。 govulncheck ./...报告No vulnerabilities found。- 本次四个提交逐个 checkout 验证均可独立编译。
- 端到端:隔离实例(simulate 模式,非默认端口,hooks 指向不存在的文件)上,同一张专辑分别下到两个不同目录均被接受并产生两个独立任务,同目的地重复提交仍返回
duplicate_active;<root>/./music/与<root>/backup/../music两种拼写正确归并到同一任务。
提交明细
feat(catalog): expose the artist's own Apple Music page as artist_url— @LYJW131fix(jobs): fold the destination into the canonical dedup key— @LYJW131、@claudebuild(deps): bump google.golang.org/grpc to v1.82.1 for GO-2026-6061— @LYJW131、@claudefix(media): give each writer its own staging file— @LYJW131、@claude