设计实践

Udio 关闭下载后,作品只存在于 udio.com 上

Udio 关闭下载后,作品只存在于 udio.com 上。自己写的提示词、歌词版本和分享链接最好在本地留一份记录。 这篇做 songlog.py:用 JSONL 记录每首歌,校验分享链接的格式,并导出 Markdown 作品目录。

BINBHO · 图文教程

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 纳入你自己的备份。

项目资源

用途 地址
Guidance tags(官方标签列表) https://help.udio.com/en/articles/11166249-level-up-your-creations-with-guidance-tags
UMG 合作带来的变化(下载已关闭) https://help.udio.com/en/articles/12683565-changes-associated-with-the-universal-music-group-umg-partnership

音频留在 Udio 上,创作过程留在自己手里:提示词、歌词和链接都有记录。