A
This commit is contained in:
1 parent
b4fc3a1c23
commit
0c55ebd747
2 files changed
+55
-8
No files matched your search
+11
@@ -0,0 +1,11 @@
|
||||
# FFmpeg (vendored Windows build)
|
||||
ffmpeg-*/
|
||||
|
||||
# Conversion outputs
|
||||
out/
|
||||
|
||||
# Local/dev artifacts
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.idea/
|
||||
+44
-8
@@ -4,19 +4,22 @@ Outputs are written flat into E:\\Music\\工具\\TypeChange\\out; if two source
|
||||
or existing outputs share the same name, the last one wins and a summary of
|
||||
overwrites is printed at the end.
|
||||
|
||||
If `mutagen` is installed, FLAC tags (Vorbis comments) and cover art are written
|
||||
to the output M4A using standard MP4/iTunes atoms (including `covr`).
|
||||
Notes:
|
||||
- If the source is already `.m4a`, it is copied as-is by default (no re-encode).
|
||||
- If `mutagen` is installed, FLAC tags (Vorbis comments) and cover art are written
|
||||
to the output M4A using standard MP4/iTunes atoms (including `covr`).
|
||||
"""
|
||||
|
||||
import os
|
||||
import argparse
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
# Source and destination roots
|
||||
SOURCE_ROOT = Path(r"E:\Music\完成\单曲")
|
||||
SOURCE_ROOT = Path(r"E:\Music")
|
||||
SKIP_ROOT = SOURCE_ROOT / "工具"
|
||||
OUT_ROOT = Path(r"E:\Music\工具\TypeChange\out")
|
||||
|
||||
@@ -294,7 +297,7 @@ def sync_tags_from_flac(src: Path, dst: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def convert_file(src: Path, dst: Path, *, tags_only: bool) -> bool:
|
||||
def convert_file(src: Path, dst: Path, *, tags_only: bool, copy_m4a: bool) -> bool:
|
||||
"""Run ffmpeg to transcode a single file to AAC. Returns True on success."""
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -313,15 +316,35 @@ def convert_file(src: Path, dst: Path, *, tags_only: bool) -> bool:
|
||||
sys.stderr.write(f"[WARN] Tag sync failed for {dst}: {exc}\n")
|
||||
return False
|
||||
|
||||
if copy_m4a and src.suffix.lower() == ".m4a":
|
||||
try:
|
||||
shutil.copy2(src, dst)
|
||||
return True
|
||||
except OSError as exc:
|
||||
sys.stderr.write(f"[FAIL] copy {src} -> {dst} ({exc})\n")
|
||||
return False
|
||||
|
||||
cmd = [FFMPEG_BIN, "-y"] # overwrite if needed
|
||||
cmd += ["-i", str(src)]
|
||||
external_cover = find_external_cover_image(src)
|
||||
if external_cover:
|
||||
cmd += ["-i", str(external_cover)]
|
||||
cmd += ["-map", "0:a:0"] # first audio stream
|
||||
if external_cover:
|
||||
cmd += ["-map", "1:v:0"] # external cover image
|
||||
cmd += ["-disposition:v:0", "attached_pic"]
|
||||
else:
|
||||
cmd += ["-map", "0:v:0?"] # embedded cover art if present
|
||||
cmd += ["-map", "0:s?"] # keep subtitle streams (some files store lyrics as subtitles)
|
||||
cmd += [
|
||||
"-map",
|
||||
"0:a", # audio only; cover art + full tags are applied in a separate step
|
||||
"-map_metadata",
|
||||
"0", # copy tags
|
||||
"-map_chapters",
|
||||
"0",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
"-c:a",
|
||||
"aac", # re-encode audio
|
||||
"-b:a",
|
||||
@@ -351,6 +374,11 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Skip transcoding; only sync FLAC tags/cover to existing outputs in OUT_ROOT.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-copy-m4a",
|
||||
action="store_true",
|
||||
help="Do not copy .m4a sources; re-encode them to AAC like other formats (default: copy).",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -398,12 +426,14 @@ def main():
|
||||
for p in preexisting_outputs:
|
||||
print(f" {p}")
|
||||
|
||||
max_workers = max(1, os.cpu_count() or 4)
|
||||
max_workers = max(1, os.cpu_count() / 2 or 4)
|
||||
successes = 0
|
||||
|
||||
print(f"Using ffmpeg at: {FFMPEG_BIN}")
|
||||
if not HAS_MUTAGEN:
|
||||
print("Note: Install 'mutagen' to fully preserve FLAC tags/cover art (pip install mutagen).")
|
||||
if not args.no_copy_m4a and not args.tags_only:
|
||||
print("Note: .m4a sources are copied as-is (use --no-copy-m4a to force re-encode).")
|
||||
if args.tags_only:
|
||||
print("Mode: tags-only (no transcoding).")
|
||||
if args.tags_only:
|
||||
@@ -415,7 +445,13 @@ def main():
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_map = {}
|
||||
for src, dst in tasks:
|
||||
future = executor.submit(convert_file, src, dst, tags_only=args.tags_only)
|
||||
future = executor.submit(
|
||||
convert_file,
|
||||
src,
|
||||
dst,
|
||||
tags_only=args.tags_only,
|
||||
copy_m4a=not args.no_copy_m4a,
|
||||
)
|
||||
future_map[future] = (src, dst)
|
||||
|
||||
for future in as_completed(future_map):
|
||||
|
||||
Reference in new issue
Block a user