手里攒了一批自己早年在 SoundCloud 上发的 demo,有的是手机直接录的,有的是废弃工程里扒出来的半成品,时间一长本地文件全丢了,只剩平台上还挂着。想把这些东西捞回本地做归档,顺手又想给一个独立音乐人的作品做离线备份,结果试了市面上七八个图形界面的 SoundCloud 音乐下载工具,没有一个能让我踏实跑完一批任务:要么下载到第三十多首就莫名卡死,要么下下来的文件时长对不上、尾部被截断,要么标签全空、封面丢失,几百个文件躺在文件夹里根本没法用。被折腾了两天之后,我干脆自己从头搭了一条链路,也就是后来我自己一直在用的这套 Soundcloud-Downloader 脚本集。
它做的事情说起来不复杂:给定一个 SoundCloud 曲目链接或者一个歌单链接,把音频流完整地拉到本地,写回标题、艺人、时长、封面这些元数据,支持批量跑、支持中途断掉之后接着跑。但真正做起来,坑几乎全藏在"音频流"这三个字后面——SoundCloud 并不是一个静态文件服务器,它把音频拆成了转码列表、签名地址、分片清单这一整套东西,你得先把这套分发结构吃透,才谈得上稳定下载。
这篇东西适合两类人看:一类是懂点 Python、想自己维护一套归档工具的开发者;另一类是纯粹想搞清楚"一个在线音频平台的文件到底是怎么被组织出来的"的技术爱好者。下面我按自己实际踩坑的顺序,把整条链路拆开讲。
1. 现成工具失灵的三个真实原因
在动手写代码之前,我花了半天时间把失败案例归了归类。搞清楚"为什么现成的不好用",比直接抄一份代码更有价值,因为后面你会发现,自己写的时候同样会撞上这三堵墙。
1.1 图形化工具的通病:把"能跑一次"当成了"能一直跑"
绝大多数现成工具的骨架都是同一套:从网页里抠出一个临时的资源地址,然后直接对着这个地址发 GET 请求存成文件。这套逻辑在平台不改版的时候确实能跑,但它有两个致命前提——第一,这个地址在你开始下载的那一刻到下载结束之前一直有效;第二,这个地址返回的是一个完整的、可以直接播放的文件。
现实是这两个前提都不稳。SoundCloud 的音频资源地址带有时效性签名,一旦你排队的曲目多、单曲体积大,排队到后面的任务拿着早就过期的地址去请求,返回的就是一个 403 页面,而很多工具会把 403 页面当成音频直接写进文件,最后你得到一堆几 KB 的、用播放器打不开的"文件"。这类问题在单曲测试时完全看不出来,一上批量就原形毕露。
1.2 元数据被彻底丢弃,导致文件"下下来也没法用"
第二个问题是元数据。直接存字节流得到的只是一个裸音频容器,里面没有任何标签信息。对于一个只有三五十首的曲库,你还能靠文件名认出来;但当你一次拉下来三百首、文件名还全是123456789.mp3这种 ID 命名的时候,这批文件在本地曲库里就是一堆垃圾。
我后来给自己定的标准是:下载完成的文件必须做到"双击能播放、音乐软件里能看到歌名和艺人、文件夹缩略图能看到封面"。这三条听起来是常识,但恰恰是最多工具做不到的地方,因为它们只管搬运字节,不管文件落到本地之后还能不能被识别。
1.3 没有断点续传,等于把网络波动放大成了灾难
第三个问题最要命。假设你排了 200 首任务,跑到第 157 首的时候网络抖了一下或者被限流了,大部分工具的处理方式是整个进程退出,或者更糟——继续往下跑,把失败的任务静默跳过。前一种你要从头再来,后一种你根本不知道自己漏了哪几首。
我需要的是一套能记录"哪些已经完成、哪些下到第几个分片"的状态机。这个需求直接把方案定死了:必须自己控制下载粒度,而不能依赖某个库的"一行代码下载整个文件"。也就是从这一步开始,我决定放弃 progressive 直链,转向 HLS 分片这条路。
2. SoundCloud 的音频分发结构:链接为什么一刷新就失效
要理解为什么地址会失效,得先看清楚 SoundCloud 是怎么把一首曲目拆开喂给浏览器的。这一步是整个项目的地基,我在这一层上反复推翻了两次设计。
2.1 页面里那坨 hydration 数据才是真正的入口
打开任意一个曲目页面,右键查看源码,你会看到一个<script>标签,里面是一段 JSON,挂在一个特定的全局变量下面。这段数据是服务端渲染时直接内联进去的,里面包含了当前曲目的完整对象:标题、时长、艺人、封面地址,以及最关键的media字段。
media下面有一个transcodings数组,这是整条链路的起点。数组里通常不止一项,每一项都描述了一种转码方式,包含三个关键字段:
| 字段 | 含义 | 实操中的注意事项 |
|---|---|---|
preset | 转码预设名,如低码率 MP3、高码率 AAC 等 | 不同曲目可用预设不一样,老曲目可能只有一两种 |
format | 容器与协议类型,常见为progressive或hls | 这两个词决定了后面走完全不同的代码分支 |
url | 获取实际媒体流的接口地址 | 这个地址本身不带签名,但它返回的内容带 |
我第一次做的时候犯了个低级错误:直接从页面 HTML 里用正则去抠.mp3结尾的字符串。结果抠出来的全是封面图的 CDN 地址,因为真正的音频地址在这个页面里压根就不出现——它需要你用transcodings里的url再去请求一次才能拿到。这个认知差是很多新手卡住的第一关。
2.2client_id的提取与失效判断
请求transcodings里的那个url时,必须带一个查询参数client_id。这个值不是固定的,它藏在页面引用的某个打包后的 JS 文件里。
提取思路很朴素:先请求曲目页面,把页面里所有<script src="...">的地址抓出来,然后逐个下载这些 JS 文件,在文本里搜索client_id附近的赋值语句,正则匹配出那一串 32 位的十六进制字符串。为了减少请求量,可以只挑文件名里带公共运行时特征的那几个脚本去扫,通常一两个文件就能命中。
这里有个必须处理的现实问题:client_id会轮换。轮换周期不固定,可能几周,也可能几天。所以架构上必须把它当成"可失效的缓存",而不是"一次抓取永久使用"。我的做法是把它连同抓取时间戳一起落盘,同时在主流程里对 401 和 403 做统一拦截——一旦命中,就清空缓存、重新抓一次、再重试当前请求,最多重试两次。这个兜底逻辑让我后面省掉了无数次手动改配置的麻烦。
注意:抓取
client_id的请求要带上一个正常的桌面浏览器 User-Agent。不带 UA 的请求有相当高的概率直接被挡回来,返回一个空页面,你会以为是正则写错了,实际上是第一步就没拿到内容。
2.3transcodings里的两条路,决定了后面所有代码结构
拿到transcodings数组之后,第一件事是按format分流。
progressive类型的项,请求它的url会返回一个 JSON,里面有一个url字段,指向一个完整的、可以直接 GET 下来的音频文件,通常是 MP3。这条路最短,代码量最少,但它有两个明显短板:一是可用性不稳定,部分曲目根本没有 progressive 项,尤其是一些较新的上传;二是码率通常不是最高的,音质上会吃点亏。
hls类型的项,请求它的url返回的同样是一个 JSON,但里面的url指向的是一个 m3u8 播放清单。这条路复杂得多,但它几乎对所有曲目都可用,而且通常能拿到更高码率的 AAC 流。
我的策略是:优先找最高码率的hls项,找不到再退回progressive。这个优先级顺序不是拍脑袋定的,而是实测下来的——HLS 那条路的资源可用率明显更高,而且因为它是分片请求,天然适合做断点续传。反过来,如果你只走 progressive,跑到一半发现某几首没有这个转码项,整个批处理逻辑就得停下来单独处理异常,体验很差。
3. 两条下载链路的具体实现与取舍
结构和入口搞明白之后,真正写代码的时候会发现两条路的工程量差了一个数量级。这一节把两条路的实现细节分别摊开,包括我实际写崩过两次的地方。
3.1 progressive 直链:最短路径和它的天花板
progressive 的实现可以说非常简单:请求 transcoding 地址拿到真实文件地址,然后流式下载写盘。用aiohttp的话,核心就是一个带iter_chunked的循环:
async def download_progressive(session, real_url, dest_path, chunk_size=1 << 16): headers = {"User-Agent": UA, "Referer": "https://soundcloud.com/"} async with session.get(real_url, headers=headers) as resp: resp.raise_for_status() total = int(resp.headers.get("Content-Length", 0)) written = 0 with open(dest_path, "wb") as fp: async for chunk in resp.content.iter_chunked(chunk_size): fp.write(chunk) written += len(chunk) if total and written != total: raise IOError(f"长度不符: 期望 {total}, 实际 {written}")关键在最后那三行。我一开始没写这个长度校验,结果出现过好几次"文件下下来了、能播放、但尾部少了两秒"的情况。原因是连接被服务端提前关闭时,客户端不一定抛异常,流就那么安静地结束了。加上Content-Length比对之后,这类问题立刻变成了显式报错,可以进入重试队列。
这条路的另一个限制是它没有分片,所以断点续传只能靠 HTTP 的Range请求头。理论上可行,但实际测试下来,服务端对Range的支持并不总是可靠,有时候会忽略这个头直接返回 200 和完整内容。所以我的最终结论是:progressive 只作为兜底方案,不做断点续传,失败就整首重下;真正承担批量任务的,是 HLS。
3.2 HLS 分片:一份 m3u8 里的 byte-range 玄机
第一次拿到 m3u8 内容的时候我是懵的。按理说 HLS 应该是一堆独立的.ts分片文件,但 SoundCloud 返回的清单长这样(简化过):
#EXTM3U #EXT-X-VERSION:6 #EXT-X-TARGETDURATION:10 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-MAP:URI="https://.../aac.m4a?range=0-8191" #EXTINF:10.0, https://.../aac.m4a?range=8192-163839 #EXTINF:10.0, https://.../aac.m4a?range=163840-319487所有分片指向的是同一个文件路径,区别只在查询参数里的range。也就是说,这是一个用 fMP4 封装的单文件,被切成了若干字节区间。EXT-X-MAP那一行是初始化段,里面包含 moov box,是解码必需的头信息。
这个结构带来两个直接影响。第一,你不能用一个简单的 HTTPRange头去下"第二个分片",必须老老实实按清单里给出的区间去请求,因为服务端校验的是 URL 里的range参数,而不是请求头。第二,合并的时候顺序不能乱,第一个字节必须是EXT-X-MAP那一段的内容,否则整个文件解不出来。
解析清单的代码本身很短,但有两个细节容易漏:
import re RANGE_RE = re.compile(r"range=(\d+)-(\d+)") def parse_playlist(text): init_url, segments = None, [] for line in text.splitlines(): line = line.strip() if not line: continue if line.startswith("#EXT-X-MAP"): m = re.search(r'URI="([^"]+)"', line) if m: init_url = m.group(1) elif not line.startswith("#"): segments.append(line) return init_url, segments第一个坑是空行和以#开头的注释行必须过滤掉,不加判断的话会把#EXTINF当成 URL 去请求。第二个坑是EXT-X-MAP的 URI 在属性列表里,需要用引号匹配,而不能简单地按=切分,因为属性串里可能还有别的键值对。
3.3 分片合并的两条技术路线
把分片都拿回来之后,怎么合并成一个可播放的文件,有两条路。
第一条是纯字节拼接:按顺序把初始化段和所有分片的bytes依次写进同一个文件。这条路简单、快、无依赖,得到的文件就是一个完整的 fMP4 容器,VLC 和大部分播放器能直接放。缺点是这个容器的索引信息可能不够规整,某些老播放器或者某些移动端会有兼容问题。
第二条是先拼后转:拼完之后调一次 ffmpeg 做流拷贝,重新封装成标准的 M4A 或者转成 MP3。
ffmpeg -hide_banner -loglevel error -y \ -i temp_concat.m4a \ -c:a copy -movflags +faststart \ output.m4a-c:a copy表示不重新编码,只重新封装,所以速度极快,几乎不消耗 CPU,音质也无损。+faststart会把索引挪到文件头部,方便边下边播的场景。
我最终的方案是:默认走第一条路,如果检测到输出文件在某些播放器上有问题,再兜底跑第二条。判断"有没有问题"的土办法是用 ffprobe 读一下时长,和曲目元数据里的时长比对,误差超过 1.5 秒就触发重新封装。这个阈值是试出来的——正常误差在 0.1 到 0.3 秒之间,一旦超过 1 秒基本就是容器结构出了岔子。
提示:拼接临时文件的时候一定要用
wb模式一次性打开、按顺序写完全部片段再关闭。我早期图省事用ab模式反复打开追加,结果在某次进程被中断后残留了一个半截文件,下次运行时又往后追加了一遍,最终文件时长翻倍,排查了很久才发现是模式选错了。
4. 让文件在本地曲库里真正可用
音频到手只是完成了一半。一个只有字节、没有标签的文件,扔进任何音乐管理软件里都是一条"未知艺人 - 未知曲目"。这一节讲元数据这块我是怎么补齐的。
4.1 MP3 的 ID3 与 M4A 的 ilst,两套完全不同的标签体系
MP3 用的是 ID3v2 标签,挂在文件头(或者尾部,v2.4 支持追加)。M4A 用的是 MP4 容器里的ilstatom,是一个嵌套的树状结构。这两套东西结构完全不同,不能混着写。
Python 生态里处理这件事最省心的库是mutagen,它对两种格式都提供了统一的高层接口。写入 M4A 的典型流程是这样:
from mutagen.mp4 import MP4, MP4Cover audio = MP4(path) audio["\xa9nam"] = [title] # 标题 audio["\xa9ART"] = [artist] # 艺人 audio["\xa9alb"] = [album] # 专辑 audio["\xa9day"] = [publish_date] # 年份 audio["\xa9gen"] = [genre] # 流派 audio["trkn"] = [(1, total)] # 音轨号 / 总数 audio.save()那几个\xa9开头的键名是 MP4 规范里的固定标识,\xa9在 Latin-1 里就是版权符号,所以写成"\xa9nam"而不是"nam"。这一点非常容易写错,而且写错了不会报错,只会静默地写不进去,你在播放器里看到的还是空标签。我第一次就是漏了\xa9前缀,白白排查了半小时。
MP3 那边则是另一套键名:
from mutagen.id3 import ID3, TIT2, TPE1, TALB, TDRC, APIC tags = ID3() tags.add(TIT2(encoding=3, text=title)) tags.add(TPE1(encoding=3, text=artist)) tags.add(TALB(encoding=3, text=album)) tags.add(TDRC(encoding=3, text=str(year))) tags.save(mp3_path)encoding=3表示 UTF-8,这个参数不能省,省了默认按 Latin-1 处理,中文标题直接乱码。这是我踩过的最典型的一个坑,而且症状很有欺骗性——文件能播放,播放器界面上显示一行问号,你以为是播放器字体问题,实际上是写入时就编错了码。
4.2 封面尺寸的一个小技巧
SoundCloud 返回的artwork_url默认是缩略图尺寸,通常是一百多像素见方,直接嵌进去在高分屏上糊得很明显。这个地址里有一个尺寸标识字段,把它替换成更大的尺寸就能拿到高清图。常见的可选尺寸从几十像素到一千像素都有,我一般用接近 500 的那一档,兼顾清晰度和文件体积。
封面下载下来之后,格式判断有个细节:artwork_url路径末尾虽然经常是.jpg,但实际返回的内容类型可能是image/png,尤其是用户自己上传的透明背景图。所以写入时要按响应的Content-Type来决定MP4Cover的imageformat参数,而不是按扩展名猜。写错了的结果是封面在某些软件里能显示、在某些软件里一片灰,很难定位。
from mutagen.mp4 import MP4Cover cover = MP4Cover(img_bytes, imageformat=MP4Cover.FORMAT_JPEG) # 或 FORMAT_PNG audio["covr"] = [cover]4.3 文件命名:别等到几百个文件堆一起才后悔
命名规则我在第一批任务跑完之后就改了三次。最终定下来的是一个兼顾排序和可读性的格式:
{艺人}/{年份} - {专辑}/{音轨号:02d} - {标题}.m4a几个具体的取舍。艺人名和专辑名里会出现斜杠、冒号、问号这些在文件系统里非法的字符,必须做一次白名单过滤,把非法字符替换掉,同时把连续空格压成一个。控制单个路径段的长度上限在 120 字符左右,超长的用标题前若干字符加曲目 ID 兜底,避免某些文件系统对路径总长有限制。曲目 ID 一定要保留在兜底名里,因为它是唯一的,能让你在出问题时反查回原页面。
另外,我习惯在下载的同时往曲目目录里写一个info.json,把原始元数据整份存下来。这东西平时用不上,但当你需要重新整理曲库、或者想批量修改标签格式的时候,它就是唯一的可信来源——因为音频文件里的标签已经被你改过好几轮了。
5. 批量下载的工程化控制
单首下载能跑通之后,真正的挑战才开始。批量任务和单任务完全是两个物种,前者要考虑并发、限流、状态持久化和失败重试,任何一环处理不好都会让整批任务的可用性大打折扣。
5.1 并发数:为什么我把默认值定在 4
第一版我图快,直接把并发拉到 20,结果跑了两百多首之后开始大面积失败,全是被限流打回来的。后来降到 8,还是有零星失败。最终稳定在 4,连续跑过几次接近一千首的任务,失败率控制在个位数。
这个数字背后有它的道理。每个下载任务实际上会产生两类请求:分片请求和元数据请求。HLS 的分片数量通常和曲目时长成正比,一首五分钟的曲子大概有三十个分片。如果你的并发是 20,而每首曲子内部又同时抓几个分片,瞬时并发会轻松突破一百,触发限流几乎是必然的。
所以我的实现里用了两层控制:外层用一个全局信号量限制同时进行的曲目数量,内层对单曲的分片请求也做了串行化或者小并发处理。
import asyncio track_sem = asyncio.Semaphore(4) async def handle_track(task): async with track_sem: return await download_one(task)分片层面我选择串行。听起来慢,但实测下来整体吞吐并不差,因为瓶颈在网络带宽和限流上,而不是在并发度上。串行反而让限流触发概率大幅下降,重试次数变少,总耗时接近甚至更优。
5.2 403 与 429 的区别对待
这两种状态码看起来都是"被拒",但处理方式完全不同,混为一谈会让逻辑变复杂。
403通常意味着签名过期或者client_id失效。这类错误重试同一个请求没有意义,必须刷新凭据后重新生成地址。我的处理是捕获 403 之后,触发一次client_id重抓,然后从头重新解析这首曲目的transcodings,拿到新地址再试。
429则是限流,意思是"你现在太频繁了,等会儿再来"。这类错误需要的是退避,而且最好是带随机抖动的指数退避,避免多个任务在同一时刻齐刷刷地重试,再次撞在一起。
async def request_with_backoff(session, url, max_retries=5): for attempt in range(max_retries): resp = await session.get(url, headers=HEADERS) if resp.status == 429: wait = (2 ** attempt) + random.uniform(0, 1.5) await asyncio.sleep(wait) continue if resp.status == 403: await refresh_client_id() raise CredentialExpired(url) return resp raise TooManyRetries(url)那个random.uniform(0, 1.5)的抖动非常关键。去掉它之后,我观察到大量任务会在同一秒集中重试,然后集体再次被拒,形成一种规律性的失败波峰。加上抖动之后,重试请求被摊平到了一个时间窗口里,成功率明显提升。
5.3 状态文件:断点续传的核心
状态管理的设计目标只有一条:任何时刻按 Ctrl+C,重新启动后能准确知道该从哪儿继续。
我用的是一份 JSON 状态文件,结构大致是任务 ID 到状态的映射:
{ "track:1029384": { "status": "partial", "title": "...", "done_segments": 17, "total_segments": 31, "init_saved": true, "updated_at": "2024-01-01T12:00:00" } }几个设计上的取舍值得说一下。第一,状态用"已完成分片数"而不是"已完成字节数"来记录,因为分片的边界是清单里明确给出的,按分片粒度恢复不会出现半截分片的问题,而按字节记录就得处理分片内部偏移,复杂且容易出错。第二,每完成一个分片就落盘一次状态,而不是整首完成才写,代价是多了些 IO,但换来的是断点精度控制在单个分片以内。第三,状态文件写入用"写临时文件 + 原子替换"的方式,避免进程在写文件过程中被杀导致状态文件损坏、整个任务列表作废。
分片本身也单独存盘,放在一个临时目录里,命名用{track_id}_{index:04d}.part。恢复的时候先检查这些文件是否存在,存在就跳过对应的网络请求。这套机制在实际用起来之后,最大的价值不是省流量,而是让我可以在网络状况不好的时候分多次把一批任务跑完,每次跑十几分钟,累计起来一样能完成。
6. 排查实录:几个折腾了很久的失败场景
前面讲的是设计,这里讲的是实际出问题时的样子。这几个案例我都完整记录过排查过程,因为它们的症状都很有欺骗性,第一次遇到基本不可能一次定位。
6.1 时长对不上:从"文件损坏"追到"初始化段缺失"
最早的症状是:下载完成的文件能播放,但播放器显示的时长比实际短,而且拖进度条到最后会卡住。用 ffprobe 一看,报告的时长只有实际的一半左右。
我最初的判断是分片没下全,于是把每个分片的字节数和清单里的区间长度逐个比对,全都对得上。接着怀疑是合并顺序错了,重新检查了一遍拼接逻辑,也没问题。
真正的突破点是拿一个"已知正常"的文件和出问题的文件做二进制对比。正常的文件开头有一段明显的ftyp和moov结构,出问题的文件开头直接就是moof。这说明初始化段没有被写进去。回头看代码,问题出在解析清单时,EXT-X-MAP那一行是带属性列表的,我的正则写得太窄,只匹配了不带引号的情况,导致init_url一直是None,而后面拼接时对None做了静默跳过——因为我在写的时候用了if init_url:这种防御性判断,反而把错误掩盖掉了。
修完之后我加了一条断言:拼接开始前,如果init_url为空就直接抛异常,绝不静默继续。这条规则后来帮我挡住了好几次类似的隐性失败。
6.2 爆音与杂音:分片错序的典型症状
另一个案例更隐蔽。文件时长完全正确,播放也正常,但在某些位置会出现很短的"咔"声。这种问题在整轨试听时很容易被忽略,是我用耳机逐段听的时候才发现的。
原因是分片的写入顺序和清单顺序不一致。我早期为了提速,用了asyncio.gather并发抓分片,然后用一个共享的写文件句柄边抓边写。并发返回的顺序是不确定的,写进去的字节自然就乱了。因为 fMP4 的分片边界对齐得比较好,乱序之后大部分数据仍能解码,但衔接处会出现采样不连续,听起来就是一声脆响。
修法很简单:并发抓取可以保留,但必须先把所有分片按索引放进一个字典,等全部完成后再按顺序写盘。或者更省内存的做法是抓完立即按索引命名存到临时目录,最后再统一顺序拼接。我选了后者,因为批量任务时内存占用更低。
6.3 其他几个高频问题速查
除了上面两个大头,还有一些零碎但高频的问题,整理成一张表方便对照:
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 单个分片请求一直超时 | 该区间的地址已过期 | 重新解析清单拿新地址,不要重试旧地址 |
| 分片全部成功但拼接失败 | 临时目录磁盘空间不足 | 下载前检查可用空间,按曲目总大小预留 |
| 标签写入报错但文件能放 | 容器实际格式与扩展名不一致 | 用 ffprobe 探测真实容器,按结果选写入器 |
| 封面在某些播放器不显示 | 图片格式标识与实际不符 | 按响应的 Content-Type 决定 imageformat |
| 批量跑一半进程静默退出 | 未捕获的异常在任务协程里抛出 | 每个任务包一层 try/except,异常记入失败队列 |
那张表里最后一条是我用血换来的。asyncio里如果某个任务协程抛了未捕获异常,而你又没有正确地await或者收集它的结果,这个异常可能就悄无声息地消失了,主流程继续往下跑,你只会发现"怎么少了几首"。所以现在我的任务调度器里,每一个任务的执行都包在一层统一的异常处理里,失败信息连同原始地址一起写进失败队列,最后统一报告。
7. 使用边界:哪些内容该下,哪些不该碰
技术上跑通之后,还有一个绕不开的问题必须说清楚,因为这决定了这套工具是"自用归档"还是"越界使用"。
7.1 平台侧的下载许可与授权协议
SoundCloud 上并不是所有曲目都允许下载。曲目对象里有一个表示下载权限的字段,创作者在发布时可以打开或关闭它。打开之后,页面上会出现下载按钮,这代表创作者明确允许听众把文件拿走。对于这类曲目,通过接口拿文件是完全符合平台设计意图的行为。
另一类是标注了开放授权协议的曲目。这类作品的权利人已经通过协议明确授予了复制和传播的许可,只要遵守协议里的署名等条件,下载和再使用都是被允许的。我在做归档的时候会优先识别这两类,把它们和普通曲目分开存放,并在info.json里记录授权信息。
至于那些既没开下载权限、也没标注开放协议的曲目,正确的做法是老老实实通过平台的收藏、歌单功能去管理,而不是绕开限制把文件拿走。这一点我在代码里做了硬性拦截:解析元数据时先检查下载许可标志,不满足条件就直接跳过并记录原因,不进入下载队列。
7.2 个人离线备份的合理尺度
即便是允许下载的曲目,使用方式也有边界。我给自己划的线是:只下载我自己上传的作品,以及明确授权下载的他人作品;下载后仅用于个人离线收听和本地归档,不再分发。这条线看起来保守,但它让整个工具的使用场景非常清晰——它是一个个人资料管理工具,而不是一个内容获取工具。
落到代码层面,我在 CLI 入口加了一个显式的确认环节,批量任务开始前会把将要下载的曲目列表和它们的授权状态打印出来,需要手动确认才会继续。这个设计一开始只是为了调试方便,后来发现它其实是个很好的自我约束机制:当你看到列表里混进了不该下的东西时,会在按下回车之前就停下来。
8. 最后一点实操体会
这套东西我从最初的一百多行脚本,慢慢长到现在带状态机、带并发控制、带元数据管道的完整工具,中间重构过三次。如果让我给准备动手的人一句建议,那会是:先把单首曲目的完整链路——从页面解析到分片合并再到标签写入——彻底跑通并且验证过,再去碰批量。
因为批量只是把单曲逻辑放进循环,真正难的部分全在单曲链路里。我见过太多人一上来就搭异步调度框架、搭状态持久化,结果单曲下载本身就有隐性 bug,等到批量跑出问题的时候,根本分不清是调度层的锅还是下载层的锅,排查成本翻好几倍。
另外,这套链路高度依赖平台当前的接口形态,任何一个环节的字段命名、清单结构、鉴权方式发生变化,代码都得跟着调整。所以我在实现时刻意把"解析"和"下载"拆成了两层,中间用一个数据结构衔接。这样接口一变,我只需要改解析层,下载层和元数据层完全不用动。这个分层在第一年里就救了我不下五次。
单曲体积方面也可以给个参考:一首五分钟左右的曲目,高码率 AAC 流一般在 8 到 12 MB 之间,分片数量在 25 到 40 个之间。按这个量级估算磁盘和带宽,比凭感觉拍数字靠谱得多。我一开始按"MP3 一首 10 MB"估,结果遇到一批较长的现场录音,单曲接近 40 MB,硬盘规划直接失准,后来改成按曲目时长动态估算才稳定下来。