From 9286f0b5b2115a7437e56cd27c167453ca0e2905 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 16 Aug 2026 15:45:55 +0800 Subject: [PATCH] transcode_music source code + doc --- transcode_music/transcode_music.md | 885 +++++++++++++++++++++++++++ transcode_music/transcode_music.py | 933 +++++++++++++++++++++++++++++ 2 files changed, 1818 insertions(+) create mode 100644 transcode_music/transcode_music.md create mode 100644 transcode_music/transcode_music.py diff --git a/transcode_music/transcode_music.md b/transcode_music/transcode_music.md new file mode 100644 index 0000000..277af04 --- /dev/null +++ b/transcode_music/transcode_music.md @@ -0,0 +1,885 @@ +# `transcode_music.py` 使用手册 + +批量将无损音乐(FLAC / WAV / DSF / DFF / APE)转码为 MP3 320k CBR 的 Python 脚本,支持断点续跑、错误恢复、CUE 整轨检测、DSD 转码。 + +--- + +## 目录 + +- [1. 简介](#1-简介) +- [2. 系统要求](#2-系统要求) +- [3. 快速上手](#3-快速上手) +- [4. 命令行参数详解](#4-命令行参数详解) +- [5. 常用使用场景](#5-常用使用场景) +- [6. Manifest 文件说明](#6-manifest-文件说明) +- [7. 文件分类规则](#7-文件分类规则) +- [8. CUE 整轨检测逻辑](#8-cue-整轨检测逻辑) +- [9. 采样率与 DSD 处理](#9-采样率与-dsd-处理) +- [10. 错误处理与恢复](#10-错误处理与恢复) +- [11. 已知局限](#11-已知局限) +- [12. FAQ](#12-faq) + +--- + +## 1. 简介 + +**这个工具解决什么问题?** + +你有一大堆无损音乐(比如 `/mnt/z/` 网盘上几百 GB 的 FLAC / WAV / DSF),想批量转成体积更小的 MP3 320k 存到手机/车载/移动设备,同时**保持原有的目录结构和文件名**,并且要能**中途暂停、下次继续**。 + +**核心特性**: + +| 特性 | 说明 | +|---|---| +| 保持目录结构 | 目标目录树与源目录一一对应,多碟专辑 (CD1/CD2) 分开处理 | +| 保留所有资源 | 封面图片 (jpg/png/…)、说明 TXT、EAC log、CUE 都会复制过去 | +| 断点续跑 | 转码状态记录在源目录的 `.transcode_manifest.json`,任意时刻中断都能从上次位置继续 | +| CUE 整轨检测 | 分析阶段自动识别 "CUE + 单个大 flac 未分轨" 的专辑,报警并跳过等你手动 split | +| CUE 已分轨识别 | 若 CUE 引用的是整轨但同时存在分轨文件,自动跳过冗余的整轨文件 | +| 高采样率处理 | 24bit / 96kHz / 192kHz 输入自动重采样到 44.1kHz (符合 MP3 规范) | +| DSD 支持 | DSF/DFF (DSD64 / DSD128) 自动解码降采样后编码 MP3 | +| 保留封面 | 内嵌于 FLAC/DSF 的封面会写入 MP3 的 ID3v2 tag | +| 错误隔离 | 单个文件失败不中断整体流程,错误写入 manifest 供后续排查 | +| 幂等 | 已完成的文件/专辑,再次运行时秒跳过 | + +--- + +## 2. 系统要求 + +| 组件 | 版本 | 说明 | +|---|---|---| +| Python | 3.10+ | 只用标准库,无需 pip install 任何依赖 | +| ffmpeg | 6.0+ | 需支持 `libmp3lame` 编码器,`dsd_lsbf/msbf` 解码器 (DSD 用) | +| ffprobe | 6.0+ | 通常随 ffmpeg 一起安装 | + +**检查环境**: + +```bash +python3 --version +ffmpeg -version | head -1 +ffprobe -version | head -1 +# 确认支持 mp3 编码 +ffmpeg -codecs 2>/dev/null | grep libmp3lame +# 确认支持 DSD 解码 (如果你有 DSF 文件) +ffmpeg -codecs 2>/dev/null | grep -i dsd +``` + +**安装 ffmpeg**(如果没有): + +```bash +# Ubuntu/Debian/WSL +sudo apt install ffmpeg + +# macOS +brew install ffmpeg +``` + +--- + +## 3. 快速上手 + +**最常用的三个命令**: + +```bash +# 1. 先分析看看有没有问题 (不实际转码) +python3 transcode_music.py /mnt/z/我的音乐 /mnt/x/mp3输出 --analyze-only + +# 2. 确认没问题后, 正式转码 (中断可再跑, 自动 resume) +python3 transcode_music.py /mnt/z/我的音乐 /mnt/x/mp3输出 + +# 3. 后来加了新专辑, 再跑一次 (已完成的会自动跳过, 只处理新加的) +python3 transcode_music.py /mnt/z/我的音乐 /mnt/x/mp3输出 +``` + +就这么简单。下面是完整的参数说明和更多用法。 + +--- + +## 4. 命令行参数详解 + +### 完整语法 + +``` +transcode_music.py [SOURCE] [TARGET] [选项...] +``` + +或 + +``` +transcode_music.py --source SOURCE --target TARGET [选项...] +``` + +### 参数列表 + +| 参数 | 短选项 | 类型 | 是否必需 | 默认 | 说明 | +|---|---|---|---|---|---| +| `SOURCE` | — | 位置参数 | 必需¹ | — | 源目录,无损音乐所在位置 | +| `TARGET` | — | 位置参数 | 必需¹ | — | 目标目录,MP3 输出位置 | +| `--source` | `-s` | 字符串 | 必需¹ | — | 源目录(与位置参数 SOURCE 等价) | +| `--target` | `-t` | 字符串 | 必需¹ | — | 目标目录(与位置参数 TARGET 等价) | +| `--bitrate` | `-b` | 字符串 | 可选 | `320k` | MP3 CBR 比特率。可选 `320k` / `256k` / `192k` / `128k` 等 | +| `--analyze-only` | — | 开关 | 可选 | 关 | 只分析生成 manifest,不实际转码 | +| `--force` | — | 开关 | 可选 | 关 | 忽略现有 manifest,全部重新分析并转码 | +| `--verbose` | `-v` | 开关 | 可选 | 关 | 显示 debug 级别日志(包括跳过的项) | +| `--ffmpeg` | — | 字符串 | 可选 | `ffmpeg` | ffmpeg 可执行文件路径(默认从 PATH 查找) | +| `--ffprobe` | — | 字符串 | 可选 | `ffprobe` | ffprobe 可执行文件路径 | +| `--timeout` | — | 整数 | 可选 | `3600` | 单文件转码超时秒数(超时算失败) | +| `--sample-rate-when-high` | — | 整数 | 可选 | `44100` | 输入采样率 > 48kHz 时的输出采样率,可选 `44100` / `48000` | +| `--help` | `-h` | 开关 | — | — | 显示帮助并退出 | + +¹ **源目录 和 目标目录 必须提供,但可以用位置参数或 flag 两种形式之一**: + +```bash +# 位置参数 (更简洁, 推荐) +transcode_music.py /mnt/z/music /mnt/x/mp3 + +# flag 长形式 +transcode_music.py --source /mnt/z/music --target /mnt/x/mp3 + +# flag 短形式 +transcode_music.py -s /mnt/z/music -t /mnt/x/mp3 + +# 混合也可以 +transcode_music.py /mnt/z/music -t /mnt/x/mp3 +``` + +### 参数详细说明 + +#### `SOURCE` / `--source` / `-s` + +**源目录**。脚本会递归扫描此目录下所有子目录,对每个包含无损音频的子目录识别为一个"专辑单元"处理。 + +- 支持含空格、中文、特殊字符的路径 +- 网盘挂载(`/mnt/z/`、`/mnt/x/` 等)都可以 +- Manifest 文件 `.transcode_manifest.json` 会写入此目录 + +#### `TARGET` / `--target` / `-t` + +**目标目录**。MP3 输出位置。目录结构会与源目录保持一致。 + +- 目录会自动创建(含中间层) +- 已存在的目标文件如果 manifest 记录 `completed` 会自动跳过 + +#### `--bitrate` / `-b` + +MP3 输出比特率(CBR,Constant Bit Rate)。默认 `320k`。 + +**推荐取值**: + +| 值 | 说明 | +|---|---| +| `320k` | 最高质量,几乎无损感知,推荐(默认) | +| `256k` | 高质量,体积减小约 20% | +| `192k` | 良好质量,体积减小约 40% | +| `128k` | 一般质量,仅在存储极度紧张时使用 | + +**注意**: 一旦确定比特率跑过一次,配置写入 manifest。**中途改比特率**不会导致已完成的文件重新转码(脚本以文件 completed 状态为准)。想改需配合 `--force`。 + +#### `--analyze-only` + +**只分析,不转码**。用途: + +- 首次跑一个新的大目录时,先看看总专辑数、CUE 整轨的有没有 +- 探测有没有解析错误的文件 +- 生成 manifest 后可以手动查看/编辑 + +**推荐**每次对新目录都先跑一次 `--analyze-only`。 + +#### `--force` + +**强制重跑**。忽略源目录现有的 manifest,重新分析并转码所有文件。 + +**什么时候用**: +- 换了比特率想全部重新转 +- 怀疑之前的输出有问题 +- 想清理并重新开始 + +**警告**:会覆盖已存在的 MP3。之前的 manifest 不会删除但会被覆盖。 + +#### `--verbose` / `-v` + +打开 DEBUG 级别日志。默认 INFO 级别只显示正在处理和错误。加上 `-v` 会额外显示: + +- `[SKIP (已完成): ...]` 每个跳过的专辑 +- `[SKIP (空目录): ...]` 跳过的空目录 +- ffprobe/ffmpeg 的调试信息 + +大量输出,通常只在排查问题时用。 + +#### `--ffmpeg` / `--ffprobe` + +指定 ffmpeg / ffprobe 的路径。默认从 `PATH` 查找。 + +**什么时候用**: +- 系统装了多个 ffmpeg 版本,想指定用哪个 +- ffmpeg 不在标准 PATH 里 + +示例: + +```bash +transcode_music.py /src /dst --ffmpeg /opt/ffmpeg-6.1/bin/ffmpeg +``` + +#### `--timeout` + +单个文件转码的超时时间(秒),默认 3600(1 小时)。超时算失败,写入 manifest 后继续处理下一个。 + +**通常不需要调整**。除非你有超长的音频文件(比如整轨 3 小时的 wav)在慢网盘上转码,可以调大。 + +#### `--sample-rate-when-high` + +高采样率输入(> 48kHz)降到多少 kHz。默认 `44100`(CD 标准)。 + +- `44100`:CD 标准,最兼容 +- `48000`:视频/DVD 标准 + +对于 24bit/96kHz、24bit/192kHz、DSD(352.8k / 705.6k)等高采样率源,MP3 编码器不支持 > 48kHz,必须降采样。默认 44.1kHz 是 CD 标准,兼容性最好。 + +如果你的音乐主要来源是从视频/电影音轨提取的,用 48000 可能更保留原有采样率。 + +--- + +## 5. 常用使用场景 + +### 场景 1: 第一次处理新目录(推荐流程) + +假设你刚下载了一大堆音乐到 `/mnt/z/新收藏/`,想转到 `/mnt/x/music/新收藏/`。 + +**第一步:先分析** + +```bash +python3 transcode_music.py /mnt/z/新收藏 /mnt/x/music/新收藏 --analyze-only +``` + +输出会告诉你: +- 总共多少个专辑 +- 多少个音频文件、多少资源 +- **有没有需要 CUE split 的专辑(`needs_cue_split`)** + +**第二步:处理 needs_cue_split 的专辑**(如果有) + +对于报警的整轨专辑,你需要用别的工具(比如 `shntool`、`CUETools`、`XLD`)手动 split 成分轨,然后再回来跑脚本。 + +**第三步:正式转码** + +```bash +python3 transcode_music.py /mnt/z/新收藏 /mnt/x/music/新收藏 +``` + +已经 `analyzed` 的专辑会开始转码,`needs_cue_split` 的会自动跳过(不影响其他)。 + +### 场景 2: 中断后恢复 + +假设转到一半按 Ctrl+C 中断了(或者断电、脚本崩溃了)。 + +只需要**再跑同样的命令**: + +```bash +python3 transcode_music.py /mnt/z/新收藏 /mnt/x/music/新收藏 +``` + +脚本会读取 manifest,跳过所有 `completed` 状态的文件和专辑,从上次中断的地方继续。**不需要任何特殊参数**。 + +### 场景 3: 增量同步(新增了专辑) + +以后你又下载了几个新专辑到 `/mnt/z/新收藏/`,直接跑: + +```bash +python3 transcode_music.py /mnt/z/新收藏 /mnt/x/music/新收藏 +``` + +已完成的专辑秒跳过,只有新目录会被分析和转码。这是最舒服的用法。 + +### 场景 4: 修复失败的文件 + +如果某个专辑因为源文件损坏等原因失败了(`status: failed`),你修复源文件后: + +```bash +# 直接再跑一次, failed 的会被自动重试 +python3 transcode_music.py /mnt/z/新收藏 /mnt/x/music/新收藏 +``` + +已成功的不会重跑,只有失败的会重试。 + +### 场景 5: 换比特率重新转码 + +想把之前的 320k 全部改成 256k: + +```bash +# 加 --force 强制重跑 +python3 transcode_music.py /mnt/z/新收藏 /mnt/x/music/新收藏 -b 256k --force +``` + +### 场景 6: 只转一小部分做质量测试 + +比如你想先转一个专辑试听下音质,别一下转几百 GB。 + +**方法一**:直接指定那个专辑的父目录 + +```bash +# 只处理 "李宗盛精选" 这一个专辑 +python3 transcode_music.py "/mnt/z/新收藏/李宗盛精选" /mnt/x/music/李宗盛精选 +``` + +**方法二**:跑 `--analyze-only` 看总数,觉得数量能接受再跑正式的。 + +### 场景 7: 只想复制资源不想转码 + +不太常见。可以用位置参数让源和目标一致(虽然实际上 target 是别的,但 --analyze-only 后手动处理): + +```bash +# 只分析生成 manifest, 不转码 +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 --analyze-only +``` + +然后手动编辑 manifest 把所有音频状态改成 `skipped` 再跑。**不推荐**,通常你就是想转码。 + +### 场景 8: 排查某个专辑的问题 + +某个专辑失败了或者行为奇怪,用 verbose 模式跑: + +```bash +python3 transcode_music.py "/mnt/z/新收藏/有问题的专辑" /mnt/x/music/test -v +``` + +输出会更详细,包括 ffprobe 探测细节、跳过原因等。 + +### 场景 9: 用不同 ffmpeg 版本 + +```bash +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 \ + --ffmpeg /opt/ffmpeg-7.0/bin/ffmpeg \ + --ffprobe /opt/ffmpeg-7.0/bin/ffprobe +``` + +--- + +## 6. Manifest 文件说明 + +### 位置 + +Manifest 保存在**源目录**下,文件名 `.transcode_manifest.json`(隐藏文件,前缀有点)。 + +### 顶层结构 + +```json +{ + "manifest_version": 1, + "created_at": "2026-08-16T15:05:03+08:00", + "updated_at": "2026-08-16T15:19:06+08:00", + "source_root": "/mnt/z/test", + "target_root": "/mnt/x/music/test", + "config": { + "bitrate": "320k", + "id3v2_version": "3", + "output_sample_rate_when_high": 44100 + }, + "stats": { + "total_albums": 5, + "by_album_status": {"completed": 5}, + "by_audio_status": {"completed": 50}, + "by_asset_status": {"completed": 7}, + "total_audio_files": 50, + "total_asset_files": 7 + }, + "albums": { + "专辑相对路径1": { ... }, + "专辑相对路径2": { ... } + } +} +``` + +### 单个专辑 (album) 结构 + +```json +{ + "status": "completed", + "issues": [], + "audio_files": { + "01 - 北风.flac": { + "type": "flac", + "size": 29671831, + "sample_rate": 44100, + "channels": 2, + "duration_sec": 297.36, + "bits_per_sample": 16, + "codec": "flac", + "status": "completed", + "target_rel": "01 - 北风.mp3", + "error": null, + "converted_at": "2026-08-16T15:18:04+08:00" + } + }, + "asset_files": { + "cover.jpg": { + "type": "image", + "size": 234567, + "status": "completed", + "target_rel": "cover.jpg", + "error": null, + "copied_at": "2026-08-16T15:18:05+08:00" + } + }, + "ignored_files": ["Thumbs.db"], + "has_audio": true, + "started_at": "2026-08-16T15:18:04+08:00", + "completed_at": "2026-08-16T15:18:06+08:00", + "error": null +} +``` + +### 状态值 (status) + +**专辑级状态**: + +| status | 说明 | 下次运行时行为 | +|---|---|---| +| `pending` | 初始状态(一般用不到) | 会重新分析 | +| `analyzed` | 已分析,等待转码 | 继续转码 | +| `processing` | 正在处理(若看到,说明上次中断了) | 会重新处理未完成部分 | +| `needs_cue_split` | 是 CUE + 整轨大文件,需先手动 split | **跳过**,不处理 | +| `completed` | 所有文件都成功了 | **秒跳过** | +| `failed` | 有文件失败 | 会重试失败的部分 | +| `skipped` | 空目录,无音频且无资源 | 跳过 | + +**文件级状态**(audio_files 和 asset_files 里): + +| status | 说明 | +|---|---| +| `pending` | 待处理 | +| `completed` | 已完成 | +| `failed` | 失败(`error` 字段有原因) | +| `skipped` | 主动跳过(如 CUE 引用的整轨已有分轨) | + +### 手动干预 manifest + +Manifest 是纯 JSON,你可以用文本编辑器直接改: + +**例子 1:让某个专辑重新处理** +```json +"status": "completed" → "status": "analyzed" +``` +把该专辑内的音频文件也改成: +```json +"status": "completed" → "status": "pending" +``` + +**例子 2:跳过某个损坏的文件** +```json +"01 - 有问题.flac": { + ... + "status": "skipped", + "error": "手动跳过,源文件有损坏" +} +``` + +**例子 3:删除某个专辑的记录,让它从头分析** +删除对应的 album 条目,下次运行会自动重新扫描添加。 + +**改完保存直接跑**: + +```bash +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 +``` + +### Manifest 损坏怎么办? + +如果 JSON 损坏(编辑错了、盘故障),脚本启动时会自动检测,将坏 manifest 重命名为 `.transcode_manifest.json.corrupt-<时间戳>`,然后**重新生成新的 manifest**。已完成的 MP3 文件不会丢,但脚本会重新扫描所有文件(对已有 MP3 的处理由目标文件是否存在来判断,见"文件级幂等")。 + +**手动恢复方法**:如果损坏的 manifest 里有大量已完成状态,可以试着修复 JSON 语法后重命名回去。 + +--- + +## 7. 文件分类规则 + +脚本会根据扩展名把文件分到几类: + +### 无损音频(会转码为 MP3) + +`.flac` `.wav` `.dsf` `.dff` `.ape` + +### 有损音频(当作资源直接复制,不转码) + +`.mp3` `.m4a` `.aac` `.ogg` `.opus` `.wma` `.mp2` + +**为什么不再压?** 因为它们已经是有损,再压一次损失更多。 + +### 图片(复制) + +`.jpg` `.jpeg` `.png` `.bmp` `.gif` `.webp` `.tif` `.tiff` + +### 文档(复制) + +`.txt` `.log` `.pdf` `.md` `.nfo` `.doc` `.docx` `.rtf` + +### CUE(复制) + +`.cue` + +### 忽略的文件(不处理) + +- `.ds_store`、`thumbs.db`、`desktop.ini`、`.directory`(系统文件) +- 以 `._` 开头的(macOS resource fork) +- `.transcode_manifest.json` 及其 tmp 中间文件 + +### 其他扩展名 + +未列出的扩展名会被当作"other"资源直接复制,不会丢。 + +--- + +## 8. CUE 整轨检测逻辑 + +CUE 文件常见有三种搭配: + +### 情况 A:CUE + 单个整轨大文件(需先 split) + +``` +album.cue (指向 album.flac, 里面 12 个 TRACK 条目) +album.flac (整张 CD 打包成一个大文件) +``` + +**处理**:脚本标记专辑状态为 `needs_cue_split`,跳过转码,在报告里警告: + +``` +[!] 1 个专辑需先手动 CUE split (已跳过转码): + • album名 + album.cue: 整轨型 (单文件 'album.flac', 12 轨) — 需先手动 CUE split 再转码 +``` + +**处理方式**:用其他工具(`shntool split`、`CUETools`、`XLD`、`foobar2000`)split 成分轨 flac 后,删除大整轨文件(或保留但用 CUE 引用改指向分轨),然后再跑脚本。 + +### 情况 B:CUE + 分轨文件(已 split,直接转码) + +``` +album.cue (指向 track01.flac, track02.flac...) +track01.flac +track02.flac +... +``` + +**处理**:脚本正常转码所有分轨 flac,CUE 文件也作为资源复制过去。 + +### 情况 C:CUE 引用整轨 + 但同时存在分轨(跳过冗余) + +``` +CDImage.cue (指向 CDImage.flac) +CDImage.flac (整轨大文件, 冗余) +(01) 曲名.flac (已经手动 split 出来的分轨) +(02) 曲名.flac +... +``` + +**处理**:脚本自动跳过 `CDImage.flac`(避免重复转码),只转分轨,同时复制 CUE。报告里会说明: + +``` +CDImage.cue: 引用 'CDImage.flac' 但存在 20 个分轨 → 跳过 'CDImage.flac' 只转分轨 +``` + +### 检测算法(技术细节) + +对每个 CUE 文件: +1. 解析出所有 `FILE "xxx" WAVE` 条目(可能有多个) +2. 解析出所有 `TRACK NN AUDIO` 条目 +3. 判断: + - 若 CUE 只引用 1 个文件,且这个文件是目录里唯一的音频 → 情况 A(整轨) + - 若 CUE 只引用 1 个文件,但目录里还有其他分轨音频 → 情况 C(跳过整轨) + - 若 CUE 引用多个文件(>= 2)→ 情况 B(分轨) + +CUE 文件的编码会尝试多种(`utf-8-sig` / `utf-8` / `gbk` / `big5` / `shift_jis` / `cp936` / `latin1`),中日韩音源基本都能识别。 + +--- + +## 9. 采样率与 DSD 处理 + +### MP3 采样率约束 + +MP3 规范只允许这些采样率:8 / 11.025 / 12 / 16 / 22.05 / 24 / 32 / 44.1 / 48 kHz。 + +超过 48kHz 的输入**必须重采样**,脚本自动处理。 + +### 具体规则 + +| 输入采样率 | 输出采样率 | 备注 | +|---|---|---| +| 44100 Hz | 44100 Hz | 透传,CD 标准 | +| 48000 Hz | 48000 Hz | 透传 | +| 32000 Hz 及其他 MP3 支持值 | 保持不变 | 透传 | +| 88200 / 96000 Hz | 44100 Hz | 降采样(默认,可通过 `--sample-rate-when-high` 改为 48000) | +| 176400 / 192000 Hz | 44100 Hz | 降采样 | +| 352800 Hz (DSD64 解码后) | 44100 Hz | 精确 8:1 降采样(DSD64 特性)| +| 705600 Hz (DSD128 解码后) | 44100 Hz | 精确 16:1 降采样 | + +### DSD (DSF / DFF) 处理 + +DSD 是 1-bit 高频(2.8 或 5.6 MHz)采样格式,与传统 PCM 不同。脚本流程: + +1. **ffprobe** 探测:报告 `codec_name=dsd_lsbf_planar` 或 `dsd_msbf_planar`,`sample_rate=352800` 或 `705600` +2. **ffmpeg 解码**:DSD → 高采样率 PCM(自带低通滤波器,96 tap,通带平坦到 48kHz,阻带抑制 ~160dB) +3. **重采样**:降到 44100 Hz +4. **编码**:libmp3lame 320k CBR + +DSD 内嵌的封面(通常是 mjpeg)会被识别为视频流并保留到 MP3 的 ID3v2 tag。 + +### 位深处理 + +- 16-bit 输入:直接编码 +- 24-bit 输入:libmp3lame 内部用 32-bit float 处理,等效于 24-bit 精度参与编码 +- DSD (1-bit):解码为 24-bit PCM 后编码 + +--- + +## 10. 错误处理与恢复 + +### 错误捕获原则 + +**单个文件失败不影响其他**。转码或复制某个文件出错时: + +1. 该文件状态设为 `failed`,错误消息写入 `error` 字段 +2. 该专辑状态设为 `failed` +3. **继续处理**下一个文件、下一个专辑 +4. 最后报告里列出所有失败项 + +### 再次运行时 + +`failed` 状态的文件会**自动重试**。已经 `completed` 的不会重跑。 + +```bash +# 修好源文件后, 直接再跑 +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 +``` + +### 常见错误 + +**"Error opening output files: Invalid argument"** + +通常是 ffmpeg 无法识别输出格式或权限问题。脚本内部已加 `-f mp3` 强制指定 muxer 避免此问题。若仍出现: +- 检查目标目录是否可写 +- 检查文件名是否有极端特殊字符 + +**"Operation not permitted"(复制时)** + +网盘挂载(9p / SMB / NFS)不允许改文件权限。脚本已经改用 `copyfile` + best-effort `utime`,通常应该没问题。若出现: +- 检查目标目录挂载参数 +- 尝试 `mount -o rw,noatime ...` 之类的选项 + +**ffprobe 失败 / 无法探测采样率** + +源文件可能损坏。检查 `manifest.audio_files.<文件>.error` 里的具体消息。 + +**ffmpeg 超时** + +默认 3600 秒。若真有超大文件慢网盘的组合,用 `--timeout 7200` 调大。 + +### 中断(Ctrl+C) + +脚本捕获 SIGINT,会保存 manifest 后退出。**中断后当前正在转的那个文件的 `.mp3.tmp` 中间文件会被清理**,不会留下坏的 MP3。 + +### 用什么方法查看失败列表 + +```bash +# 用 jq 快速查看失败的专辑 +jq -r '.albums | to_entries[] | select(.value.status=="failed") | .key' \ + /mnt/z/music/.transcode_manifest.json + +# 查看某个专辑里失败的文件 +jq '.albums["专辑名"].audio_files | with_entries(select(.value.status=="failed"))' \ + /mnt/z/music/.transcode_manifest.json +``` + +### 关于 needs_cue_split + +`needs_cue_split` 状态的专辑**不算失败**,是主动跳过等你手动处理。若不需要转这些,可以: + +**选项 1:修 manifest 忽略它们** + +把状态改成 `skipped`: + +```json +"status": "needs_cue_split" → "status": "skipped" +``` + +**选项 2:手动 CUE split** + +用其他工具 split 成分轨,然后: + +```bash +# 让脚本重新分析 +rm /mnt/z/music/.transcode_manifest.json +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 --analyze-only +``` + +--- + +## 11. 已知局限 + +- **不支持并行转码**。是单进程顺序处理。CPU 密集型(LAME 编码)加网盘 IO 是主要瓶颈。多进程可以显著加速,但为保证 manifest 状态一致性没有实现。若性能是问题,可以对不同子目录同时跑多个脚本实例。 +- **不支持 VBR MP3**。只做 CBR。原因:CBR 兼容性更好,且 320k CBR 与 V0 VBR 音质差异对绝大多数人不可闻。 +- **不做 replaygain / ID3v1 补齐 / 章节标记**。只做基本的 ID3v2.3 元数据映射。 +- **CUE split 不会自动做**。检测出整轨专辑后只是报警跳过,不代替你调用外部工具去 split。设计如此以避免不可控的副作用。 +- **不支持 ALAC (.m4a) 转 MP3**。因为 `.m4a` 容器里既可能是 ALAC 无损也可能是 AAC 有损,无法从扩展名区分。这些文件会被当作有损直接复制。若你有确定是 ALAC 的 `.m4a`,需要先用 ffmpeg 手动转成 flac。 +- **网盘 mtime 不能保留**。9p / SMB 挂载不允许改 mtime,所以复制后的资源文件时间戳是当下时间(内容完全一致)。转码后的 MP3 也是同样情况。 +- **不做重复检测**。若源目录有两个不同路径下的同名同内容文件,会各自转码两份。 + +--- + +## 12. FAQ + +**Q: 我可以中途改比特率吗?** + +A: 已经 `completed` 的文件不会重转,即使你改了 `-b`。想全部按新比特率重跑就加 `--force`。 + +**Q: 手机不支持 320k 会怎样?** + +A: 所有正常的 MP3 播放器都支持 320k CBR,几十年的老设备也没问题。 + +**Q: 转码后 MP3 音质怎么样?** + +A: LAME 320k CBR 是"透明比特率"级别,即绝大多数听者在双盲测试中无法区分 320k MP3 和原始 FLAC。除非你有非常好的耳机和听音训练,几乎听不出差别。 + +**Q: DSD 转 MP3 有没有意义?** + +A: DSD 主要意义在于极高频响应(超过人耳范围)和低失真。转成 MP3 后这些优势基本没了。但 MP3 320k 仍能保留 DSD 音源的音乐性和大部分动态。若你就是想要个手机能播的版本,DSD → MP3 是合理的。想要"无损感"应该保留 FLAC。 + +**Q: 支持 Windows 直接跑吗?** + +A: 脚本用了 POSIX 的 `os.fsync(dir_fd)` 做原子写,Windows 上会 best-effort(fsync dir 会 fail 但不影响正确性)。理论上可以跑,但没实测。**推荐 Linux / WSL2 / macOS**。 + +**Q: 网盘断连了怎么办?** + +A: 单文件失败会记录到 manifest 并继续。重新连上后再跑一次,失败的会自动重试。 + +**Q: 生成的 manifest 会不会很大?** + +A: 一般不大。50 个专辑(我的测试目录)的 manifest 约 26 KB。上千个专辑估计几 MB。不影响使用。 + +**Q: 我想只转某几个专辑怎么办?** + +A: 最简单的办法是让 SOURCE 指向那个专辑的父目录: + +```bash +python3 transcode_music.py "/mnt/z/music/张学友精选" /mnt/x/mp3/张学友精选 +``` + +或者临时把想转的目录复制/软链接到一个专用目录,跑完再删。 + +**Q: 能不能顺便做元数据整理(比如加艺人、专辑年份)?** + +A: 目前不做。脚本只做 `-map_metadata 0` 把源文件的 tag 原样映射过去。想批量整理元数据推荐 `beets`、`Picard`。 + +**Q: 转码完想验证质量怎么办?** + +A: 抽几首用 `ffprobe` 看输出的 `bit_rate` 应该是 320000 左右,`sample_rate` 是 44100 或 48000。 + +```bash +ffprobe -v error -show_entries "stream=codec_name,sample_rate,bit_rate" \ + -show_entries "format=bit_rate,duration" \ + "/mnt/x/mp3/xxx.mp3" +``` + +或用播放器听一下确认。 + +--- + +## 附录 A:完整命令示例集 + +```bash +# 分析(不转码) +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 --analyze-only + +# 正式转码(自动 resume) +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 + +# 详细日志 +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 -v + +# 使用 256k 比特率 +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 -b 256k + +# 强制重跑 +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 --force + +# 使用 48kHz 输出(视频 / DVD 场景) +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 --sample-rate-when-high 48000 + +# 使用长参数 +python3 transcode_music.py --source /mnt/z/music --target /mnt/x/mp3 + +# 使用短参数 +python3 transcode_music.py -s /mnt/z/music -t /mnt/x/mp3 + +# 组合:位置 + 选项 +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 -b 256k -v --force + +# 单独指定 ffmpeg 路径 +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 \ + --ffmpeg /opt/ffmpeg-7/bin/ffmpeg \ + --ffprobe /opt/ffmpeg-7/bin/ffprobe + +# 单文件超长的场景,加大超时 +python3 transcode_music.py /mnt/z/music /mnt/x/mp3 --timeout 7200 +``` + +## 附录 B:文件结构示例 + +**源目录**(真实的音乐盘结构示例): + +``` +/mnt/z/新收藏/ +├── 张学友精选/ +│ ├── 01 - 吻别.flac ← 转成 mp3 +│ ├── 02 - 一路上有你.flac ← 转成 mp3 +│ ├── cover.jpg ← 复制 +│ └── 专辑说明.txt ← 复制 +├── 王菲 - 天空 (24bit-192kHz)/ +│ ├── 01. 天空.flac ← 24/192 → 降采样后转 mp3 +│ ├── 02. 誓言.flac +│ └── booklet.jpg +├── 蔡琴 2CD 精选/ +│ ├── back.jpg ← 顶层封面, 复制 +│ ├── cover.jpg +│ ├── 目录.txt +│ ├── CD1/ ← 独立处理 +│ │ ├── 01 恰似你的温柔.wav ← 转成 mp3 +│ │ └── 02 你的眼神.wav +│ └── CD2/ +│ └── 01 出塞曲.wav +├── DSD 专辑/ +│ └── 谭咏麟-一生中最爱.dsf ← DSD 解码后转 mp3 +└── 整轨专辑 [FLAC+CUE]/ ← ⚠ needs_cue_split + ├── CDImage.flac ← 整个 CD 一个大文件 + └── CDImage.cue ← CUE 索引 +``` + +**目标目录**(脚本处理后): + +``` +/mnt/x/mp3/新收藏/ +├── 张学友精选/ +│ ├── 01 - 吻别.mp3 ← 320k CBR +│ ├── 02 - 一路上有你.mp3 +│ ├── cover.jpg +│ └── 专辑说明.txt +├── 王菲 - 天空 (24bit-192kHz)/ +│ ├── 01. 天空.mp3 ← 44.1kHz 320k +│ ├── 02. 誓言.mp3 +│ └── booklet.jpg +├── 蔡琴 2CD 精选/ +│ ├── back.jpg +│ ├── cover.jpg +│ ├── 目录.txt +│ ├── CD1/ +│ │ ├── 01 恰似你的温柔.mp3 +│ │ └── 02 你的眼神.mp3 +│ └── CD2/ +│ └── 01 出塞曲.mp3 +├── DSD 专辑/ +│ └── 谭咏麟-一生中最爱.mp3 ← 320k +└── 整轨专辑 [FLAC+CUE]/ ← 空(因为 needs_cue_split 被跳过) +``` + +**Manifest**(在源目录): + +``` +/mnt/z/新收藏/.transcode_manifest.json +``` diff --git a/transcode_music/transcode_music.py b/transcode_music/transcode_music.py new file mode 100644 index 0000000..7f8b445 --- /dev/null +++ b/transcode_music/transcode_music.py @@ -0,0 +1,933 @@ +#!/usr/bin/env python3 +""" +transcode_music.py - 批量转码无损音乐 (FLAC/WAV/DSF/APE) 为 MP3 320k CBR. + +CLI tool. Walks source dir, transcodes lossless audio to MP3, copies covers/txt/cue, +tracks state in a JSON manifest for resumable runs. Detects CUE+whole-disc albums +that need manual splitting and reports them without attempting transcode. + +Dependencies: Python 3.10+, ffmpeg, ffprobe. + +Usage: + # Analyze only (recommended first run to catch CUE issues) + python3 transcode_music.py -s /mnt/z/test -t /mnt/x/music/test --analyze-only + + # Full run (auto-resumes, skips completed) + python3 transcode_music.py -s /mnt/z/test -t /mnt/x/music/test + + # Force re-run (ignore existing manifest) + python3 transcode_music.py -s /mnt/z/test -t /mnt/x/music/test --force +""" +from __future__ import annotations + +import argparse +import datetime as dt +import errno +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import threading +from dataclasses import asdict, dataclass +from enum import Enum +from pathlib import Path +from typing import Optional + +__version__ = "1.0.0" + +MANIFEST_SCHEMA_VERSION = 1 +MANIFEST_NAME = ".transcode_manifest.json" + +LOSSLESS_EXTS = {".flac", ".wav", ".dsf", ".dff", ".ape"} +LOSSY_EXTS = {".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wma", ".mp2"} +IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tif", ".tiff"} +DOCUMENT_EXTS = {".txt", ".log", ".pdf", ".md", ".nfo", ".doc", ".docx", ".rtf"} +CUE_EXTS = {".cue"} + +IGNORE_NAMES = {".ds_store", "thumbs.db", "desktop.ini", ".directory"} +IGNORE_PREFIXES = ("._",) # macOS resource forks — not user content + +# LAME/MP3 spec: only these sample rates are legal for MP3 output. +# Inputs outside this set (high-res 96/192k, DSD 352.8/705.6k) MUST be resampled. +MP3_SAMPLE_RATES = {8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000} + + +class Status(str, Enum): + PENDING = "pending" + ANALYZED = "analyzed" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + NEEDS_CUE_SPLIT = "needs_cue_split" + SKIPPED = "skipped" + + +@dataclass +class Config: + source_root: Path + target_root: Path + bitrate: str = "320k" + id3v2_version: str = "3" + output_sample_rate_when_high: int = 44100 + keep_source_mtime: bool = True + force: bool = False + analyze_only: bool = False + ffmpeg: str = "ffmpeg" + ffprobe: str = "ffprobe" + ffmpeg_timeout_sec: int = 3600 + + +@dataclass +class AudioFile: + type: str + size: int + sample_rate: Optional[int] = None + channels: Optional[int] = None + duration_sec: Optional[float] = None + bits_per_sample: Optional[int] = None + codec: Optional[str] = None + status: str = Status.PENDING.value + target_rel: str = "" + error: Optional[str] = None + converted_at: Optional[str] = None + + +@dataclass +class AssetFile: + type: str + size: int + status: str = Status.PENDING.value + target_rel: str = "" + error: Optional[str] = None + copied_at: Optional[str] = None + + +def _now_iso() -> str: + return dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="seconds") + + +def _to_int(x) -> Optional[int]: + if x in (None, "", "N/A"): + return None + try: + return int(x) + except (TypeError, ValueError): + return None + + +def _to_float(x) -> Optional[float]: + if x in (None, "", "N/A"): + return None + try: + return float(x) + except (TypeError, ValueError): + return None + + +def atomic_write_json(path: Path, data: dict) -> None: + payload = json.dumps(data, ensure_ascii=False, indent=2) + tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp") + + fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) + try: + os.write(fd, payload.encode("utf-8")) + try: + os.fsync(fd) + except OSError: + pass # network mounts (CIFS/NFS) may not implement fsync + finally: + os.close(fd) + + os.replace(str(tmp), str(path)) + + try: + dfd = os.open(str(path.parent), os.O_RDONLY) + try: + os.fsync(dfd) # persist the rename itself (durability guarantee) + except OSError: + pass + finally: + os.close(dfd) + except OSError: + pass + + +def quarantine_corrupt(path: Path, reason: str) -> Path: + ts = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S") + q = path.with_name(f"{path.name}.corrupt-{ts}") + try: + os.replace(str(path), str(q)) + logging.error(f"Manifest 损坏, 已隔离到 {q}: {reason}") + except OSError as e: + logging.error(f"隔离失败 {path}: {e}") + return q + + +def ffprobe_audio(path: Path, cfg: Config) -> dict: + try: + result = subprocess.run( + [ + cfg.ffprobe, "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=codec_name,sample_rate,channels,bits_per_raw_sample", + "-show_entries", "format=duration", + "-of", "json", + str(path), + ], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode != 0: + return {"error": (result.stderr or "ffprobe failed").strip()[-200:]} + data = json.loads(result.stdout or "{}") + stream = (data.get("streams") or [{}])[0] + fmt = data.get("format") or {} + return { + "codec": stream.get("codec_name"), + "sample_rate": _to_int(stream.get("sample_rate")), + "channels": _to_int(stream.get("channels")), + "duration_sec": _to_float(fmt.get("duration")), + "bits_per_sample": _to_int(stream.get("bits_per_raw_sample")), + } + except subprocess.TimeoutExpired: + return {"error": "ffprobe timeout"} + except (json.JSONDecodeError, OSError) as e: + return {"error": str(e)} + + +def choose_output_sample_rate(input_rate: Optional[int], prefer_when_high: int) -> Optional[int]: + if input_rate is None: + return prefer_when_high + if input_rate in MP3_SAMPLE_RATES: + return None + return prefer_when_high + + +def transcode_to_mp3( + src: Path, + dst: Path, + input_sample_rate: Optional[int], + cfg: Config, +) -> tuple[bool, Optional[str]]: + dst.parent.mkdir(parents=True, exist_ok=True) + tmp = dst.with_name(dst.name + ".tmp") + if tmp.exists(): + try: + tmp.unlink() + except OSError: + pass + + out_rate = choose_output_sample_rate(input_sample_rate, cfg.output_sample_rate_when_high) + + cmd = [ + cfg.ffmpeg, "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-i", str(src), + "-c:a", "libmp3lame", + "-b:a", cfg.bitrate, + ] + if out_rate is not None: + cmd += ["-ar", str(out_rate)] + cmd += [ + "-map_metadata", "0", + "-id3v2_version", cfg.id3v2_version, + "-map", "0:a", + "-map", "0:v?", # optional embedded cover art + "-c:v", "copy", + "-f", "mp3", # explicit muxer: tmp filename has .tmp suffix, cannot auto-detect + str(tmp), + ] + + orig_mtime: Optional[float] = None + if cfg.keep_source_mtime: + try: + orig_mtime = src.stat().st_mtime + except OSError: + pass + + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=cfg.ffmpeg_timeout_sec, + ) + if result.returncode != 0: + stderr = (result.stderr or "").strip() + err_msg = stderr.splitlines()[-1] if stderr else f"ffmpeg exit {result.returncode}" + _try_unlink(tmp) + return False, err_msg[-400:] + + if orig_mtime is not None: + try: + os.utime(str(tmp), (orig_mtime, orig_mtime)) + except OSError: + pass + + try: + os.replace(str(tmp), str(dst)) # same target-drive rename → atomic + except OSError as e: + if e.errno == errno.EXDEV: + shutil.move(str(tmp), str(dst)) # cross-device fallback (rare) + else: + raise + return True, None + + except subprocess.TimeoutExpired: + _try_unlink(tmp) + return False, f"ffmpeg 超时 ({cfg.ffmpeg_timeout_sec}s)" + except (OSError, subprocess.SubprocessError) as e: + _try_unlink(tmp) + return False, str(e) + + +def _try_unlink(p: Path) -> None: + try: + if p.exists(): + p.unlink() + except OSError: + pass + + +def copy_asset(src: Path, dst: Path, _cfg: Config) -> tuple[bool, Optional[str]]: + try: + dst.parent.mkdir(parents=True, exist_ok=True) + src_size = src.stat().st_size + if dst.exists(): + try: + if dst.stat().st_size == src_size: + return True, None # size-only idempotency (9p mounts can't preserve mtime) + except OSError: + pass + + shutil.copyfile(str(src), str(dst)) + try: + src_mtime = src.stat().st_mtime + os.utime(str(dst), (src_mtime, src_mtime)) + except OSError: + pass # 9p / SMB mounts often reject chmod/utime; content copy still succeeded + return True, None + except OSError as e: + return False, str(e) + + +CUE_FILE_RE = re.compile(r'^\s*FILE\s+"([^"]+)"\s+(\w+)', re.IGNORECASE) +CUE_TRACK_RE = re.compile(r'^\s*TRACK\s+(\d+)\s+(\w+)', re.IGNORECASE) + +# CUE files from Chinese/Japanese/Korean releases often use legacy encodings. +CUE_ENCODINGS = ("utf-8-sig", "utf-8", "gbk", "big5", "shift_jis", "cp936", "latin1") + + +def read_cue_text(path: Path) -> Optional[str]: + try: + raw = path.read_bytes() + except OSError: + return None + for enc in CUE_ENCODINGS: + try: + return raw.decode(enc) + except UnicodeDecodeError: + continue + return None + + +def parse_cue(path: Path) -> Optional[dict]: + text = read_cue_text(path) + if text is None: + return None + files: list[dict] = [] + tracks: list[dict] = [] + counter = 0 + for line in text.splitlines(): + m = CUE_FILE_RE.match(line) + if m: + counter += 1 + files.append({"name": m.group(1), "format": m.group(2), "counter": counter}) + continue + m = CUE_TRACK_RE.match(line) + if m: + tracks.append({"num": int(m.group(1)), "file_counter": counter}) + return {"files": files, "tracks": tracks} + + +def analyze_cue_situation( + cue_files: list[Path], + audio_files_by_name: dict[str, Path], +) -> dict: + """ + Classify CUE + audio combinations. Returns: + { + "needs_split": bool, # True => album must be manually CUE-split before transcode + "skip_audio": set[str], # audio filenames to skip (redundant whole-disc file) + "issues": list[str], # human-readable notes/warnings + } + + Whipper counter-pattern logic: + - CUE with 1 FILE and that file is the only audio → whole-disc → needs_split + - CUE with 1 FILE but per-track audio also present → skip the whole-disc file + - CUE with multiple FILEs → per-track index, transcode all + - CUE references missing files → note but continue + """ + result = {"needs_split": False, "skip_audio": set(), "issues": []} + + for cue in cue_files: + parsed = parse_cue(cue) + if parsed is None: + result["issues"].append(f"{cue.name}: 无法解析 (编码问题)") + continue + + refs = parsed["files"] + num_files = len(refs) + num_tracks = len(parsed["tracks"]) + + if num_files == 0: + result["issues"].append(f"{cue.name}: 无 FILE entry") + continue + + refs_present = [r["name"] for r in refs if r["name"] in audio_files_by_name] + refs_missing = [r["name"] for r in refs if r["name"] not in audio_files_by_name] + + if num_files == 1: + ref_name = refs[0]["name"] + if ref_name in audio_files_by_name: + other_audio = [n for n in audio_files_by_name if n != ref_name] + if not other_audio: + result["needs_split"] = True + result["issues"].append( + f"{cue.name}: 整轨型 (单文件 '{ref_name}', {num_tracks} 轨) " + f"— 需先手动 CUE split 再转码" + ) + else: + result["skip_audio"].add(ref_name) + result["issues"].append( + f"{cue.name}: 引用 '{ref_name}' 但存在 {len(other_audio)} 个分轨 " + f"→ 跳过 '{ref_name}' 只转分轨" + ) + else: + result["issues"].append( + f"{cue.name}: 引用 '{ref_name}' 但文件不存在" + ) + else: + if refs_missing: + result["issues"].append( + f"{cue.name}: {len(refs_missing)}/{num_files} 个引用文件缺失 " + f"(例: {refs_missing[:2]})" + ) + if len(refs_present) < 2: + result["issues"].append( + f"{cue.name}: 多 FILE 引用但仅 {len(refs_present)} 个存在, 请人工核查" + ) + + return result + + +def classify_file(path: Path) -> str: + name = path.name.lower() + if name in IGNORE_NAMES: + return "ignore" + for pref in IGNORE_PREFIXES: + if name.startswith(pref): + return "ignore" + if MANIFEST_NAME.lower() in name: + return "ignore" + + ext = path.suffix.lower() + if ext in LOSSLESS_EXTS: + return "audio-lossless" + if ext in LOSSY_EXTS: + return "audio-lossy" + if ext in IMAGE_EXTS: + return "image" + if ext in DOCUMENT_EXTS: + return "document" + if ext in CUE_EXTS: + return "cue" + return "other" + + +def find_all_dirs(source_root: Path) -> list[Path]: + result = [] + for dirpath, dirnames, _ in os.walk(source_root): + dirnames.sort() + for d in dirnames: + result.append(Path(dirpath) / d) + return sorted(result) + + +def analyze_directory(dir_path: Path, cfg: Config) -> dict: + entry = { + "status": Status.PENDING.value, + "issues": [], + "audio_files": {}, + "asset_files": {}, + "ignored_files": [], + "has_audio": False, + "started_at": None, + "completed_at": None, + "error": None, + } + + try: + items = sorted(dir_path.iterdir()) + except OSError as e: + entry["status"] = Status.FAILED.value + entry["error"] = f"无法读取目录: {e}" + return entry + + file_paths = [p for p in items if p.is_file()] + + audio_paths: list[Path] = [] + cue_paths: list[Path] = [] + + for f in file_paths: + cat = classify_file(f) + try: + stat = f.stat() + except OSError as e: + entry["issues"].append(f"stat '{f.name}' 失败: {e}") + continue + + rel = f.name + + if cat == "ignore": + entry["ignored_files"].append(rel) + continue + + if cat == "audio-lossless": + info = ffprobe_audio(f, cfg) + af = AudioFile( + type=f.suffix.lower().lstrip("."), + size=stat.st_size, + sample_rate=info.get("sample_rate"), + channels=info.get("channels"), + duration_sec=info.get("duration_sec"), + bits_per_sample=info.get("bits_per_sample"), + codec=info.get("codec"), + target_rel=str(Path(rel).with_suffix(".mp3")), + ) + if "error" in info: + af.error = info["error"] + entry["issues"].append(f"ffprobe '{rel}': {info['error']}") + entry["audio_files"][rel] = asdict(af) + audio_paths.append(f) + elif cat == "cue": + entry["asset_files"][rel] = asdict( + AssetFile(type="cue", size=stat.st_size, target_rel=rel) + ) + cue_paths.append(f) + elif cat in ("image", "document"): + entry["asset_files"][rel] = asdict( + AssetFile(type=cat, size=stat.st_size, target_rel=rel) + ) + elif cat == "audio-lossy": + entry["asset_files"][rel] = asdict( + AssetFile(type=f.suffix.lower().lstrip("."), size=stat.st_size, target_rel=rel) + ) + else: + entry["asset_files"][rel] = asdict( + AssetFile(type="other", size=stat.st_size, target_rel=rel) + ) + + entry["has_audio"] = bool(audio_paths) + + if cue_paths and audio_paths: + audio_by_name = {p.name: p for p in audio_paths} + cue_result = analyze_cue_situation(cue_paths, audio_by_name) + entry["issues"].extend(cue_result["issues"]) + if cue_result["needs_split"]: + entry["status"] = Status.NEEDS_CUE_SPLIT.value + else: + for skip_name in cue_result["skip_audio"]: + if skip_name in entry["audio_files"]: + entry["audio_files"][skip_name]["status"] = Status.SKIPPED.value + entry["audio_files"][skip_name]["error"] = "CUE 引用的整轨, 已有分轨故跳过" + entry["status"] = Status.ANALYZED.value + elif audio_paths: + entry["status"] = Status.ANALYZED.value + else: + if entry["asset_files"]: + entry["status"] = Status.ANALYZED.value + else: + entry["status"] = Status.SKIPPED.value + + return entry + + +def merge_entry(existing: dict, fresh: dict) -> dict: + for name, af in fresh.get("audio_files", {}).items(): + old = (existing.get("audio_files") or {}).get(name) + if old and old.get("status") == Status.COMPLETED.value: + if old.get("size") == af.get("size"): + af["status"] = Status.COMPLETED.value + af["converted_at"] = old.get("converted_at") + af["error"] = None + + for name, ae in fresh.get("asset_files", {}).items(): + old = (existing.get("asset_files") or {}).get(name) + if old and old.get("status") == Status.COMPLETED.value: + if old.get("size") == ae.get("size"): + ae["status"] = Status.COMPLETED.value + ae["copied_at"] = old.get("copied_at") + ae["error"] = None + + if existing.get("started_at"): + fresh["started_at"] = existing["started_at"] + + _refresh_album_status(fresh) + return fresh + + +def _refresh_album_status(entry: dict) -> None: + if entry["status"] in (Status.NEEDS_CUE_SPLIT.value, Status.SKIPPED.value): + return + file_statuses = [] + file_statuses.extend(af.get("status") for af in entry.get("audio_files", {}).values()) + file_statuses.extend(ae.get("status") for ae in entry.get("asset_files", {}).values()) + if not file_statuses: + return + unique = set(file_statuses) + if unique.issubset({Status.COMPLETED.value, Status.SKIPPED.value}) \ + and Status.COMPLETED.value in unique: + entry["status"] = Status.COMPLETED.value + if not entry.get("completed_at"): + entry["completed_at"] = _now_iso() + elif Status.FAILED.value in unique: + entry["status"] = Status.FAILED.value + + +class Manifest: + def __init__(self, path: Path, cfg: Config): + self.path = path + self.cfg = cfg + self.data: dict = {} + + def load_or_init(self) -> None: + if self.path.exists() and not self.cfg.force: + try: + text = self.path.read_text(encoding="utf-8") + data = json.loads(text) + if not isinstance(data, dict) or not isinstance(data.get("albums"), dict): + raise ValueError("bad structure") + self.data = data + logging.info(f"读取 manifest: {len(self.data['albums'])} 个专辑记录") + return + except (json.JSONDecodeError, ValueError, OSError) as e: + quarantine_corrupt(self.path, str(e)) + self.data = self._new() + + def _new(self) -> dict: + now = _now_iso() + return { + "manifest_version": MANIFEST_SCHEMA_VERSION, + "created_at": now, + "updated_at": now, + "source_root": str(self.cfg.source_root), + "target_root": str(self.cfg.target_root), + "config": { + "bitrate": self.cfg.bitrate, + "id3v2_version": self.cfg.id3v2_version, + "output_sample_rate_when_high": self.cfg.output_sample_rate_when_high, + }, + "stats": {}, + "albums": {}, + } + + def save(self) -> None: + self.data["updated_at"] = _now_iso() + self.data["stats"] = self._compute_stats() + atomic_write_json(self.path, self.data) + + def _compute_stats(self) -> dict: + by_album: dict[str, int] = {} + by_audio: dict[str, int] = {} + by_asset: dict[str, int] = {} + total_audio = 0 + total_asset = 0 + for album in self.data.get("albums", {}).values(): + s = album.get("status", "unknown") + by_album[s] = by_album.get(s, 0) + 1 + for af in album.get("audio_files", {}).values(): + fs = af.get("status", "unknown") + by_audio[fs] = by_audio.get(fs, 0) + 1 + total_audio += 1 + for ae in album.get("asset_files", {}).values(): + fs = ae.get("status", "unknown") + by_asset[fs] = by_asset.get(fs, 0) + 1 + total_asset += 1 + return { + "total_albums": len(self.data.get("albums", {})), + "by_album_status": by_album, + "by_audio_status": by_audio, + "by_asset_status": by_asset, + "total_audio_files": total_audio, + "total_asset_files": total_asset, + } + + +def analyze_all(cfg: Config, manifest: Manifest, log: logging.Logger) -> None: + log.info(f"扫描 {cfg.source_root} ...") + all_dirs = find_all_dirs(cfg.source_root) + log.info(f"发现 {len(all_dirs)} 个子目录") + + for d in all_dirs: + rel = str(d.relative_to(cfg.source_root)) + existing = manifest.data["albums"].get(rel) + if existing and existing.get("status") == Status.COMPLETED.value and not cfg.force: + log.debug(f"[已完成 skip] {rel}") + continue + log.info(f"[分析] {rel}") + fresh = analyze_directory(d, cfg) + if existing: + fresh = merge_entry(existing, fresh) + manifest.data["albums"][rel] = fresh + + manifest.save() + + +def process_all(cfg: Config, manifest: Manifest, log: logging.Logger) -> None: + albums = manifest.data["albums"] + keys = sorted(albums.keys()) + total = len(keys) + + for i, rel in enumerate(keys, 1): + album = albums[rel] + status = album.get("status") + + if status == Status.COMPLETED.value and not cfg.force: + log.debug(f"[{i}/{total}] SKIP (已完成): {rel}") + continue + if status == Status.NEEDS_CUE_SPLIT.value: + log.warning(f"[{i}/{total}] SKIP (需 CUE split): {rel}") + continue + if status == Status.SKIPPED.value: + log.debug(f"[{i}/{total}] SKIP (空目录): {rel}") + continue + + log.info(f"[{i}/{total}] 处理: {rel}") + album["status"] = Status.PROCESSING.value + if album.get("started_at") is None: + album["started_at"] = _now_iso() + manifest.save() + + source_album = cfg.source_root / rel + target_album = cfg.target_root / rel + + album_ok = True + + for name, af in album.get("audio_files", {}).items(): + if af.get("status") == Status.COMPLETED.value and not cfg.force: + continue + if af.get("status") == Status.SKIPPED.value: + continue + + src = source_album / name + dst = target_album / af["target_rel"] + log.info(f" 转码: {name} ({af.get('type', '?')}, " + f"{af.get('sample_rate') or '?'}Hz)") + ok, err = transcode_to_mp3(src, dst, af.get("sample_rate"), cfg) + if ok: + af["status"] = Status.COMPLETED.value + af["error"] = None + af["converted_at"] = _now_iso() + else: + af["status"] = Status.FAILED.value + af["error"] = err + album_ok = False + log.error(f" ✗ 失败: {err}") + manifest.save() + + for name, ae in album.get("asset_files", {}).items(): + if ae.get("status") == Status.COMPLETED.value and not cfg.force: + continue + src = source_album / name + dst = target_album / ae["target_rel"] + log.info(f" 复制: {name} ({ae.get('type', '?')})") + ok, err = copy_asset(src, dst, cfg) + if ok: + ae["status"] = Status.COMPLETED.value + ae["error"] = None + ae["copied_at"] = _now_iso() + else: + ae["status"] = Status.FAILED.value + ae["error"] = err + album_ok = False + log.error(f" ✗ 失败: {err}") + manifest.save() + + album["status"] = Status.COMPLETED.value if album_ok else Status.FAILED.value + if album_ok: + album["completed_at"] = _now_iso() + manifest.save() + + +def print_report(manifest: Manifest, log: logging.Logger) -> None: + albums = manifest.data.get("albums", {}) + stats = manifest.data.get("stats") or {} + + log.info("") + log.info("=" * 70) + log.info("摘要") + log.info("=" * 70) + log.info(f"总专辑数: {stats.get('total_albums', 0)}") + for k, v in sorted((stats.get("by_album_status") or {}).items()): + log.info(f" · album {k}: {v}") + log.info(f"音频文件: {stats.get('total_audio_files', 0)}") + for k, v in sorted((stats.get("by_audio_status") or {}).items()): + log.info(f" · audio {k}: {v}") + log.info(f"资源文件: {stats.get('total_asset_files', 0)}") + for k, v in sorted((stats.get("by_asset_status") or {}).items()): + log.info(f" · asset {k}: {v}") + + needs_split = [(r, a) for r, a in albums.items() + if a.get("status") == Status.NEEDS_CUE_SPLIT.value] + if needs_split: + log.warning("") + log.warning(f"[!] {len(needs_split)} 个专辑需先手动 CUE split (已跳过转码):") + for r, a in needs_split: + log.warning(f" • {r}") + for issue in a.get("issues", []): + log.warning(f" {issue}") + + failed = [(r, a) for r, a in albums.items() + if a.get("status") == Status.FAILED.value] + if failed: + log.error("") + log.error(f"[x] {len(failed)} 个专辑失败 (再次运行会重试):") + for r, a in failed: + log.error(f" • {r}") + audio_errs = [(n, af.get("error")) + for n, af in a.get("audio_files", {}).items() + if af.get("status") == Status.FAILED.value] + asset_errs = [(n, ae.get("error")) + for n, ae in a.get("asset_files", {}).items() + if ae.get("status") == Status.FAILED.value] + for n, e in audio_errs[:3]: + log.error(f" ✗ audio {n}: {e}") + for n, e in asset_errs[:2]: + log.error(f" ✗ asset {n}: {e}") + + +def main() -> int: + ap = argparse.ArgumentParser( + description="批量转码无损音乐 (FLAC/WAV/DSF/APE) 为 MP3 320k, 带 manifest 状态追踪与断点续跑.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""示例: + # 最简用法 (位置参数) + %(prog)s /mnt/z/test /mnt/x/music/test + + # 仅分析, 生成 manifest (推荐首次运行时用来排查 CUE 整轨等问题) + %(prog)s /mnt/z/test /mnt/x/music/test --analyze-only + + # 使用命名参数形式 (与位置参数等价) + %(prog)s -s /mnt/z/test -t /mnt/x/music/test + + # 强制重跑 (忽略现有 manifest) + %(prog)s /mnt/z/test /mnt/x/music/test --force + + # 使用 256k 比特率 + %(prog)s /mnt/z/test /mnt/x/music/test -b 256k +""", + ) + ap.add_argument("source_pos", nargs="?", metavar="SOURCE", + help="源目录 (位置参数, 如 /mnt/z/test). 也可用 --source/-s") + ap.add_argument("target_pos", nargs="?", metavar="TARGET", + help="目标目录 (位置参数, 如 /mnt/x/music/test). 也可用 --target/-t") + ap.add_argument("--source", "-s", dest="source_flag", + help="源目录 (等价于第 1 个位置参数)") + ap.add_argument("--target", "-t", dest="target_flag", + help="目标目录 (等价于第 2 个位置参数)") + ap.add_argument("--bitrate", "-b", default="320k", + help="MP3 比特率 (默认 320k). 可用 256k/192k 等") + ap.add_argument("--analyze-only", action="store_true", + help="只分析生成 manifest, 不实际转码") + ap.add_argument("--force", action="store_true", + help="忽略现有 manifest, 强制重新分析并转码全部") + ap.add_argument("--verbose", "-v", action="store_true", help="详细日志") + ap.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg 路径 (默认从 PATH 查)") + ap.add_argument("--ffprobe", default="ffprobe", help="ffprobe 路径") + ap.add_argument("--timeout", type=int, default=3600, + help="单文件转码超时秒数 (默认 3600)") + ap.add_argument("--sample-rate-when-high", type=int, default=44100, + choices=[44100, 48000], + help="输入采样率>48kHz时的输出采样率, 默认 44100 (CD 标准)") + args = ap.parse_args() + + source_arg = args.source_pos or args.source_flag + target_arg = args.target_pos or args.target_flag + if not source_arg or not target_arg: + ap.error("必须提供源目录和目标目录 (位置参数 SOURCE TARGET, 或 --source/--target)") + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)-5s %(message)s", + datefmt="%H:%M:%S", + ) + log = logging.getLogger("transcode") + + source = Path(source_arg).resolve() + target = Path(target_arg).resolve() + + if not source.exists(): + log.error(f"源目录不存在: {source}") + return 2 + if not source.is_dir(): + log.error(f"源路径不是目录: {source}") + return 2 + + try: + target.mkdir(parents=True, exist_ok=True) + except OSError as e: + log.error(f"无法创建目标目录 {target}: {e}") + return 2 + + cfg = Config( + source_root=source, + target_root=target, + bitrate=args.bitrate, + force=args.force, + analyze_only=args.analyze_only, + ffmpeg=args.ffmpeg, + ffprobe=args.ffprobe, + ffmpeg_timeout_sec=args.timeout, + output_sample_rate_when_high=args.sample_rate_when_high, + ) + + manifest_path = source / MANIFEST_NAME + manifest = Manifest(manifest_path, cfg) + manifest.load_or_init() + + log.info(f"源目录: {source}") + log.info(f"目标目录: {target}") + log.info(f"Manifest: {manifest_path}") + log.info(f"比特率: {cfg.bitrate}") + log.info(f"高采样率降到: {cfg.output_sample_rate_when_high} Hz") + log.info(f"模式: {'仅分析' if cfg.analyze_only else '分析 + 转码'}") + log.info(f"Resume: {'关闭 (--force)' if cfg.force else '开启 (跳过已完成)'}") + log.info("") + + try: + analyze_all(cfg, manifest, log) + + if cfg.analyze_only: + print_report(manifest, log) + log.info("") + log.info("仅分析模式完成. 请查看 manifest, 处理 needs_cue_split 的专辑后再跑一次.") + return 0 + + process_all(cfg, manifest, log) + print_report(manifest, log) + + except KeyboardInterrupt: + log.warning("") + log.warning("用户中断 (Ctrl+C) - 保存 manifest 后退出") + try: + manifest.save() + except OSError as e: + log.error(f"保存 manifest 失败: {e}") + return 130 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())