transcode_music source code + doc
This commit is contained in:
885
transcode_music/transcode_music.md
Normal file
885
transcode_music/transcode_music.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
933
transcode_music/transcode_music.py
Normal file
933
transcode_music/transcode_music.py
Normal file
@@ -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())
|
||||||
Reference in New Issue
Block a user