Нарезка аудиокниги по главам: треки с ID3-тегами вместо одного слитного файла

This commit is contained in:
Илья Поляков
2026-08-03 11:02:32 +03:00
parent 93c40c76bf
commit a0c26b41bf
4 changed files with 227 additions and 7 deletions
+133
View File
@@ -169,6 +169,127 @@ def check_final(audiobook, total_chars, rate):
return True
def chapter_fragments(temp, num):
"""Фрагменты главы в порядке воспроизведения: вводный, затем абзацы."""
intro = temp / ("chapter_%03d_intro.mp3" % num)
paras = sorted(temp.glob("chapter_%03d_para_*.mp3" % num))
return ([intro] if intro.exists() else []) + paras
def audio_params(sample):
"""Параметры кодирования первого фрагмента — чтобы тишина совпала и
склейка прошла без перекодирования."""
r = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a:0",
"-show_entries", "stream=sample_rate,channels,bit_rate",
"-of", "default=nw=1", str(sample)],
capture_output=True, text=True)
p = dict(l.split("=", 1) for l in r.stdout.strip().splitlines() if "=" in l)
return (p.get("sample_rate", "24000"), p.get("channels", "1"),
p.get("bit_rate", "48000"))
def make_silence(path, seconds, params):
rate, channels, bitrate = params
subprocess.run(
["ffmpeg", "-v", "error", "-y", "-f", "lavfi",
"-i", "anullsrc=r=%s:cl=%s" % (rate, "mono" if channels == "1" else "stereo"),
"-t", str(seconds), "-b:a", bitrate, str(path)],
check=True)
def concat(files, out, tmp, meta):
"""Склейка через concat-демультиплексор. Сначала без перекодирования;
если mp3-потоки не сошлись — пересжатие."""
listing = tmp / "concat.txt"
listing.write_text(
"".join("file '%s'\n" % str(f.resolve()).replace("'", r"'\''") for f in files),
encoding="utf-8")
tags = []
for k, v in meta.items():
tags += ["-metadata", "%s=%s" % (k, v)]
base = ["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0",
"-i", str(listing)]
r = subprocess.run(base + ["-c", "copy"] + tags + [str(out)],
capture_output=True, text=True)
if r.returncode == 0 and out.exists() and out.stat().st_size:
return True
r = subprocess.run(base + ["-c:a", "libmp3lame"] + tags + [str(out)],
capture_output=True, text=True)
if r.returncode:
print(" ffmpeg:", r.stderr.strip()[:200])
return False
return True
def safe_name(s, limit=70):
s = re.sub(r"[/\\\x00-\x1f]", " ", s)
s = re.sub(r"\s+", " ", s).strip(" .")
return s[:limit] or "Без названия"
def cmd_split(args):
"""Треки по главам вместо одного слитного файла."""
temp = args.audiobook / "temp_audio"
if not temp.is_dir():
sys.exit("нет %s — нарезать не из чего. Каталог удаляется методом "
"cleanup_temp_files() стороннего скрипта: режь до уборки." % temp)
expect = expected_fragments(args.translations)
titles = {}
for f in sorted(args.translations.glob("chapter_*_translated*.json")):
n = int(re.search(r"chapter_(\d+)", f.name).group(1))
titles[n] = json.loads(f.read_text(encoding="utf-8")).get("title", "")
args.output.mkdir(parents=True, exist_ok=True)
work = args.output / ".tmp"
work.mkdir(exist_ok=True)
first = next(iter(sorted(temp.glob("*.mp3"))), None)
if first is None:
sys.exit("в %s нет mp3" % temp)
params = audio_params(first)
silence = None
if args.gap > 0:
silence = work / "silence.mp3"
make_silence(silence, args.gap, params)
made = skipped = 0
for n in sorted(expect):
frags = chapter_fragments(temp, n)
want = expect[n]["paragraphs"] + 1
if len(frags) < want and not args.force:
print("глава %03d: %d из %d фрагментов — пропущена (--force чтобы всё равно)"
% (n, len(frags), want))
skipped += 1
continue
seq = frags
if silence:
seq = []
for f in frags:
seq += [f, silence]
seq = seq[:-1]
title = safe_name(titles.get(n) or ("Глава %d" % n))
out = args.output / ("%03d - %s.mp3" % (n, title))
meta = {"title": title, "track": "%d/%d" % (n + 1, len(expect)),
"album": args.album, "artist": args.author,
"album_artist": args.author, "genre": "Audiobook"}
if concat(seq, out, work, meta):
made += 1
else:
print("глава %03d: склейка не удалась" % n)
for f in work.glob("*"):
f.unlink()
work.rmdir()
total = sum(duration(f) or 0 for f in args.output.glob("*.mp3"))
size = sum(f.stat().st_size for f in args.output.glob("*.mp3")) / 1024 / 1024
print("\nтреков: %d, пропущено глав: %d, звучание %.1f ч, %.0f МБ"
% (made, skipped, total / 3600, size))
print("каталог: %s" % args.output)
return skipped == 0 and made > 0
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -184,6 +305,18 @@ def main():
v.add_argument("audiobook", type=Path, help="каталог audiobook/")
v.set_defaults(func=cmd_verify)
s = sub.add_parser("split", help="нарезать по главам вместо одного файла")
s.add_argument("translations", type=Path, help="каталог, поданный в озвучку")
s.add_argument("audiobook", type=Path, help="каталог audiobook/ с temp_audio/")
s.add_argument("output", type=Path, help="куда сложить треки")
s.add_argument("--album", default="Аудиокнига", help="название книги для тегов")
s.add_argument("--author", default="", help="автор для тегов")
s.add_argument("--gap", type=float, default=0.3,
help="пауза между абзацами, секунд (0 — без пауз)")
s.add_argument("--force", action="store_true",
help="резать даже главы с недостающими фрагментами")
s.set_defaults(func=cmd_split)
args = ap.parse_args()
result = args.func(args)
if result is False: