Udio 关闭下载后,作品只存在于 udio.com 上。自己写的提示词、歌词版本和分享链接最好在本地留一份记录。 这篇做
song_log.py:用 JSONL 记录每首歌,校验分享链接的格式,并导出 Markdown 作品目录。
先看清成品
- 链接
...?utm_source=share会自动去掉参数后保存;下载站链接、http 链接、udio.com.evil.example这类伪装域名都会被拒绝。- 修复了两个 bug:手动删行后 id 重复;我自己改代码时把注释插进 dict,导致 KeyError。
- pytest 7 项通过。
合规提醒:根据 Udio 帮助中心,自 2025-10-29 与 Universal Music Group 合作起,音频、视频和分轨的下载已经关闭,歌曲只能通过 udio.com 链接分享。本文因此不涉及任何下载、录制或转换 Udio 音频的做法。网上那些“Udio 下载器”和录屏方法违反官方规则,不要使用。提示词里不要写在世艺人的名字,也不要使用他人受版权保护的歌词;Udio 表示会过滤对艺人声音和版权作品的复制。不共享账号。本文不列价格,套餐和额度以官网为准。
运行说明:box 上没有 Udio 账号,没有生成过歌曲。本地脚本和测试都在 box 上真实运行过(Python 3.13;C 篇使用 pronouncing 0.3.0 和 CMU 发音词典)。示例歌词是我自己写的;示例中的 Udio 链接 ID 是虚构的。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
song_log.py add/list/export |
记录、列出和导出作品 |
songs.jsonl |
本地记录(运行后生成) |
catalog.md |
作品目录 |
test_song_log.py |
7 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | 3.9 以上(box 上是 3.13);测试需要 pip install pytest |
| Udio | 网页版 https://www.udio.com,在 Create → Custom 中粘贴歌词 |
关键事实
| 事实 | 来源 |
|---|---|
| 自 2025-10-29 起,音频、视频和分轨的下载被关闭 | UMG 合作带来的变化(2026-02-17 更新) |
分享链接的格式是 https://www.udio.com/songs/<id> |
Udio 歌曲页面的地址(搜索结果中的公开歌曲页) |
Custom 模式下在歌词框按 / 可以选择标签;官方列出了 28 个标签,但说明这不是完整列表,也不是硬性指令 |
Guidance tags |
链接中 id 的长度,我按看到的 22 位字母数字,放宽为 10~40 位;官方没有公布格式规范。
文件结构
67-udio-B-src/
├── song_log.py
├── song.txt
└── test_song_log.py
第 1 步:记录脚本
"""Udio 创作记录:在本地记录每首歌的提示词、歌词版本、Udio 分享链接和备注,并导出 Markdown 作品目录。
背景:自 2025-10-29 起,Udio 关闭了音频、视频和分轨的下载(帮助中心 UMG partnership 一文),歌曲只能通过 udio.com 链接分享。
所以这里**只保存链接和你自己的文字**,不下载、不录制音频。"""
import argparse, datetime as dt, hashlib, json, os, re, sys
LINK = re.compile(r"^https://(www\.)?udio\.com/songs/[A-Za-z0-9]{10,40}$")
TZ = dt.timezone(dt.timedelta(hours=8))
def load(path):
return [json.loads(l) for l in open(path, encoding="utf-8")] if os.path.exists(path) else []
def clean_link(link):
"""浏览器复制的地址可能带 ?参数、#锚点 或结尾的 /,统一去掉后再校验。"""
return re.split(r"[?#]", link.strip())[0].rstrip("/") if link else link
def add(path, title, prompt, lyrics_file=None, link=None, model="", notes=""):
link = clean_link(link)
if link and not LINK.match(link):
raise SystemExit(f"不是 Udio 歌曲链接:{link}(格式应为 https://www.udio.com/songs/<id>)")
rows = load(path)
if link and any(r.get("link") == link for r in rows):
raise SystemExit(f"这个链接已经记录过:{link}")
lyr = open(lyrics_file, encoding="utf-8").read() if lyrics_file else ""
# id 用最大值 + 1,不用 len()+1:手动删行后会重号
row = {"id": max((r["id"] for r in rows), default=0) + 1, "title": title, "prompt": prompt, "lyrics": lyr,
"lyrics_sha256": hashlib.sha256(lyr.encode()).hexdigest()[:12] if lyr else None,
"link": link, "model": model, "notes": notes, "created": dt.datetime.now(TZ).isoformat(timespec="seconds")}
with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(row, ensure_ascii=False) + "\n")
return row
def export_md(rows):
out = ["# 我的 Udio 作品目录", "", "> 歌曲只能在 udio.com 上播放(下载已关闭)。以下提示词和歌词由我本人撰写。", ""]
for r in rows:
out += [f"## {r['id']}. {r['title']}", "", f"- 链接:{r['link'] or '(未发布)'}", f"- 提示词:`{r['prompt']}`",
f"- 模型:{r['model'] or '未记录'} 记录时间:{r['created']}(UTC+8)"]
if r["lyrics"]: out += [f"- 歌词版本:`{r['lyrics_sha256']}`", "", "```text", r["lyrics"].rstrip(), "```"]
if r["notes"]: out += ["", f"备注:{r['notes']}"]
out.append("")
return "\n".join(out)
def main(argv=None):
ap = argparse.ArgumentParser(); ap.add_argument("--log", default="songs.jsonl"); sub = ap.add_subparsers(dest="cmd", required=True)
a1 = sub.add_parser("add"); a1.add_argument("title"); a1.add_argument("--prompt", required=True); a1.add_argument("--lyrics")
a1.add_argument("--link"); a1.add_argument("--model", default=""); a1.add_argument("--notes", default="")
sub.add_parser("list"); e = sub.add_parser("export"); e.add_argument("--out", default="catalog.md")
a = ap.parse_args(argv)
if a.cmd == "add":
r = add(a.log, a.title, a.prompt, a.lyrics, a.link, a.model, a.notes); print("已记录", r["id"], r["title"])
elif a.cmd == "list":
for r in load(a.log): print(r["id"], r["title"], r["link"] or "-", r["lyrics_sha256"] or "-")
else:
open(a.out, "w", encoding="utf-8").write(export_md(load(a.log))); print("写入", a.out)
return 0
if __name__ == "__main__":
sys.exit(main())
第 2 步:测试
import json, pytest
import song_log as S
L = "https://www.udio.com/songs/AbCdEfGhIjKlMnOpQrStUv"
def test_add_and_export(tmp_path):
log = str(tmp_path / "s.jsonl")
r = S.add(log, "Coastal Road", "indie pop", "song.txt", L, "v1.5")
assert r["id"] == 1 and len(r["lyrics_sha256"]) == 12 and r["created"].endswith("+08:00")
md = S.export_md(S.load(log))
assert L in md and "[Verse 1]" in md and "下载已关闭" in md
@pytest.mark.parametrize("bad", ["https://example-downloader.site/udio?u=1", "http://www.udio.com/songs/AbCdEfGhIjKlMnOpQrStUv",
"https://udio.com.evil.example/songs/AbCdEfGhIjKl", "https://www.udio.com/create"])
def test_rejects_non_udio(tmp_path, bad):
with pytest.raises(SystemExit): S.add(str(tmp_path / "s.jsonl"), "x", "p", link=bad)
def test_link_cleanup_and_duplicate(tmp_path):
log = str(tmp_path / "s.jsonl")
assert S.add(log, "a", "p", link=L + "?utm_source=share#top")["link"] == L
with pytest.raises(SystemExit, match="已经记录过"): S.add(log, "b", "p", link=L + "/")
def test_ids_after_manual_delete(tmp_path):
# 调试时发现:手动删掉一行后,用 len()+1 会出现重复的 id 3
log = tmp_path / "s.jsonl"
for t in "abc": S.add(str(log), t, "p")
lines = log.read_text(encoding="utf-8").splitlines(); log.write_text("\n".join([lines[0], lines[2]]) + "\n", encoding="utf-8")
assert S.add(str(log), "d", "p")["id"] == 4
第 3 步:运行
$ python song_log.py add "Coastal Road" --prompt "a summer road trip, indie pop, bright guitars" --lyrics song.txt \
--link "https://www.udio.com/songs/AbCdEfGhIjKlMnOpQrStUv?utm_source=share" --model "v1.5" --notes "示例链接,ID 是虚构的"
已记录 1 Coastal Road
$ python song_log.py add "Demo 2" --prompt "lofi study beat, instrumental"
已记录 2 Demo 2
$ python song_log.py add "Bad" --prompt x --link https://example-downloader.site/udio?u=1
不是 Udio 歌曲链接:https://example-downloader.site/udio?u=1(格式应为 https://www.udio.com/songs/<id>)
$ python song_log.py list
1 Coastal Road https://www.udio.com/songs/AbCdEfGhIjKlMnOpQrStUv 9e16ab83159d
2 Demo 2 - -
$ python song_log.py export
写入 catalog.md
$ python -m pytest -q
7 passed
怎么检查结果?
catalog.md中每首歌都有链接、提示词和歌词版本号。- 修改歌词后再记录一次,歌词版本号(sha256 的前 12 位)会变化,便于区分版本。
- 重复的链接会被拒绝。
调试示例
1. 手动删行后 id 重复。 第一版的 id 是 len(rows) + 1。我记录了 3 首歌,用 sed 删掉第 2 行,再新增一首:结果新歌的 id 也是 3,和原来的第 3 首重复:
1 Coastal Road ...
3 Third - -
3 Fourth - -
改为“现有最大 id + 1”,并加了回归测试 test_ids_after_manual_delete。
2. 我自己改代码引入的 bug。 修复上面的问题时,我把注释直接写在了 dict 字面量那一行的中间:row = {"id": ..., # 注释 "title": title, ...}。注释后面同一行的 "title"、"prompt" 等键都被注释掉了,测试立刻报 KeyError: 'title'。把注释移到上一行后恢复正常。
3. 浏览器复制的链接带参数。 带 ?utm_source=... 的链接一开始无法通过校验。现在先去掉 ?、# 后面的部分和结尾的 /,再校验并去重。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 链接被拒绝 | 不是 https://www.udio.com/songs/... 格式 | 从 Udio 的 Share → Copy Link 复制 |
| 想保存音频 | 官方已关闭下载 | 只保存链接;不要使用第三方下载器 |
| id 不连续 | 手动删过记录 | 正常现象,id 不会重复 |
模板规则
- 只记录链接和自己的文字,不保存 Udio 的音频。
- 每次修改歌词都会生成新的版本号。
songs.jsonl纳入你自己的备份。
项目资源
音频留在 Udio 上,创作过程留在自己手里:提示词、歌词和链接都有记录。