enchires.py 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. # Version: 1.0
  2. import os
  3. import math
  4. import time
  5. import signal
  6. import struct
  7. import traceback
  8. from mmap import mmap
  9. from pathlib import Path, PurePath
  10. from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter, ArgumentTypeError
  11. from subprocess import Popen, PIPE
  12. from multiprocessing import Pool
  13. class Result():
  14. def __init__(self, path: str):
  15. self.path = path
  16. class SuccessResult(Result, Exception):
  17. def __init__(self, path: str, savings: int):
  18. super().__init__(path)
  19. self.savings = savings
  20. class ErrorResult(Result, Exception):
  21. def __init__(self, path: str, err: str):
  22. super().__init__(path)
  23. self.err = err.replace("\r\n", "\n").strip()
  24. """Utils"""
  25. def type_folder(value: str):
  26. if not Path(value).is_dir():
  27. raise ArgumentTypeError(f"invalid folder path: {value}")
  28. return value
  29. def walk(path: str):
  30. for path, _, files in os.walk(path):
  31. for file in files:
  32. if file.endswith(".flac"):
  33. yield PurePath(path).joinpath(file).as_posix()
  34. def process(args: list[str]):
  35. proc = Popen(args, stdout=PIPE, stderr=PIPE)
  36. if proc.wait() == 0:
  37. return proc.stdout.read().decode().replace("\r\n", "\n")
  38. else:
  39. raise ErrorResult(None, proc.stderr.read().decode().replace("\r\n", "\n"))
  40. """Worker"""
  41. def get_flac_info(path: str):
  42. with open(path, "r+b") as f:
  43. with mmap(f.fileno(), length=4096) as m:
  44. m.seek(18)
  45. if m[0] != 0x66 or m[1] != 0x4C or m[2] != 0x61 or m[3] != 0x43:
  46. raise ErrorResult(path, "Invalid FLAC file header")
  47. return {
  48. "sr": struct.unpack('>I', m.read(4))[0] >> 12
  49. }
  50. def get_flac_size(path: str):
  51. return os.stat(path).st_size
  52. def get_flac_md5(path: str):
  53. return process(["metaflac", "--show-md5sum", path]).split("\n")[0]
  54. def work(path: str):
  55. if get_flac_info(path)["sr"] < args.samplerate:
  56. return None
  57. old_size = get_flac_size(path)
  58. old_md5 = get_flac_md5(path)
  59. tmp_path = path + ".enchires"
  60. process(["ffmpeg", "-hide_banner", "-i", path, "-f", "flac", "-compression_level", str(args.level), "-y", tmp_path])
  61. process(["metaflac", "--dont-use-padding", "--remove", "--block-type=PICTURE,PADDING", tmp_path])
  62. process(["metaflac", "--add-padding=8192", tmp_path])
  63. if old_md5 != get_flac_md5(path):
  64. return ErrorResult(path, "MD5 mismatched after processing")
  65. os.replace(tmp_path, path)
  66. return SuccessResult(path, old_size - get_flac_size(path))
  67. def work_handler(path: str):
  68. try:
  69. return work(path)
  70. except SuccessResult as res:
  71. return res
  72. except ErrorResult as res:
  73. res.path = path
  74. return res
  75. except Exception as ex:
  76. return ErrorResult(path, "".join(traceback.format_exception(type(ex), ex, ex.__traceback__)))
  77. def work_init():
  78. signal.signal(signal.SIGINT, signal.SIG_IGN)
  79. """Main"""
  80. parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter, description="Encode hires (>=88.2kHz) flac with ffmpeg to save space.")
  81. parser.add_argument("path", type=type_folder, help="path to music")
  82. parser.add_argument("-l", dest="level", type=int, help="ffmpeg compression level", default=11)
  83. parser.add_argument("-sr", dest="samplerate", type=int, help="minimum sample rate to process", default=88200)
  84. parser.add_argument("-w", dest="workers", type=int, help="parallel encoder count", default=math.floor(os.cpu_count() * .8))
  85. args = parser.parse_args()
  86. if __name__ == "__main__":
  87. timestamp = time.time()
  88. log_success = open("log_success.txt", "a+", encoding="utf-8")
  89. log_error = open("log_error.txt", "a+", encoding="utf-8")
  90. pool = Pool(args.workers, work_init)
  91. count = 0
  92. savings = 0
  93. for res in pool.imap_unordered(work_handler, walk(args.path)):
  94. if res is None:
  95. continue
  96. count += 1
  97. if isinstance(res, SuccessResult):
  98. savings += res.savings
  99. log_success.write(f"{res.path}\n")
  100. elif isinstance(res, ErrorResult):
  101. log_error.write(f"------------\n{res.path}\n------------\n\n{res.err}\n\n\n")
  102. if count % max(args.workers, 10) == 0:
  103. print(f" {count:>7} | {savings / 1024 / 1024: >7.0f} MiB | {PurePath(res.path).relative_to(args.path).parent.as_posix()}")
  104. log_success.flush()
  105. log_error.flush()
  106. print(f" {count:>7} | {savings / 1024 / 1024: >7.0f} MiB")
  107. pool.close()
  108. log_success.close()
  109. log_error.close()
  110. print(f"Finished in {(time.time() - timestamp) / 60:.0f} minutes")