Automated music library format conversion with cuesheet detection, tagging support and configurable regex to obtain tags from filenames. Configuration with ini-files to support multiple locations with multiple quality requirements.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

239 lines
8.1 KiB

  1. CUE_SHEET_EXTENSION: str = ".cue"
  2. import os
  3. import shutil
  4. import logging
  5. import subprocess
  6. import sys
  7. import re
  8. from joblib import Parallel, delayed
  9. from configparser import ConfigParser
  10. from copy import deepcopy
  11. from pathlib import Path
  12. from typing import List, Optional, Tuple
  13. from ffcuesplitter.cuesplitter import FFCueSplitter
  14. CONFIG_FILE: str = sys.argv[1]
  15. config = ConfigParser()
  16. config.read(CONFIG_FILE)
  17. SRC_FOLDER: str = config.get("source", "folder")
  18. SRC_FILE_EXTS: List[str] = config.get("source", "file_exts").split(",")
  19. SRC_ICON_EXTS: List[str] = config.get("source", "icon_exts").split(",")
  20. DST_FOLDER: str = config.get("destination", "folder")
  21. DST_FILE_EXT: str = config.get("destination", "file_ext")
  22. DST_OVERWRITE: bool = config.get("libconv", "overwrite") == "true"
  23. DRY_RUN: bool = config.get("libconv", "dry_run") != "false"
  24. DEDUCE_METADATA: bool = config.get("libconv", "deduce_metadata") == "true"
  25. FFMPEG_LOCATION: str = config.get("ffmpeg", "location")
  26. FFMPEG_OPTS: str = config.get("ffmpeg", "options")
  27. THREADS: int = int(config.get("libconv", "threads"))
  28. if DST_OVERWRITE:
  29. FFMPEG_OPTS += " -y"
  30. logging.basicConfig(level=logging.INFO)
  31. cmd_list: List[str] = []
  32. # ----------------------------------------------------------------- #
  33. # file and folder operations
  34. def copy(src: str, dst: str):
  35. logging.debug(f"copy {src} to {dst}")
  36. if not DRY_RUN:
  37. shutil.copyfile(src, dst, follow_symlinks=True)
  38. def mkdir(path: str):
  39. logging.debug(f"mkdir -r {path}")
  40. if not DRY_RUN:
  41. os.makedirs(path, exist_ok=True)
  42. def execute(command: str):
  43. logging.debug(f"execute: {command}")
  44. if not DRY_RUN:
  45. cmd_list.append(command)
  46. # ----------------------------------------------------------------- #
  47. # cue sheet processing
  48. def cue_sheet_processor(path: str):
  49. dst_folder, preexisting = prepare_destination(path)
  50. if preexisting and not DST_OVERWRITE:
  51. logging.info(f"Album {path} already exists in output location and overwrite is disabled - skipping")
  52. return
  53. sheet = get_files_with_ext(path, [CUE_SHEET_EXTENSION])
  54. args = []
  55. if (len(sheet) != 1):
  56. logging.error(f"Expected exactly one but {path} contains {len(sheet)} cue sheets - trying file processor")
  57. file_processor(path)
  58. return
  59. try:
  60. data = FFCueSplitter(sheet[0], dry=DRY_RUN, outputdir=dst_folder, suffix=DST_FILE_EXT, overwrite=DST_OVERWRITE, ffmpeg_cmd=FFMPEG_LOCATION, ffmpeg_add_params=FFMPEG_OPTS)
  61. data.open_cuefile()
  62. args = data.ffmpeg_arguments()["arguments"]
  63. except:
  64. logging.error(f"Error during parsing of cuesheet {path} - trying file processor")
  65. file_processor(path)
  66. return
  67. for arg in args:
  68. arg_end = arg.find("-y")
  69. filename = arg[arg_end + 5:-1]
  70. arg = arg[:arg_end]
  71. execute(f"{arg} \"{dst_folder}{filename}\"")
  72. # ----------------------------------------------------------------- #
  73. # basic "folder contains audio files" processing
  74. def metadata_from_folder(path: str) -> str:
  75. # this method has to be adapted to your individual folder structure
  76. # if there is none then do nothing as by default this is not used
  77. if not DEDUCE_METADATA:
  78. return ""
  79. path = os.path.normpath(path)
  80. folders = path.split(os.sep)
  81. logging.debug(f"deducing metadata from folders: {folders}")
  82. album_name = re.sub(r'[ ]*\(.*?\)', '', folders[-1])
  83. comment = folders[-1].replace(album_name, "").replace("(", "").replace(")", "").replace(" ","")
  84. logging.debug(f"got metadata: album={album_name} comment={comment} artist={folders[-2]} genre={folders[-3]}")
  85. return f"-metadata ALBUM=\"{album_name}\" -metadata COMMENT=\"{comment}\" -metadata ARTIST=\"{folders[-2]}\" -metadata GENRE=\"{folders[-3]}\""
  86. def metadata_from_file(filename: str) -> str:
  87. title=""
  88. number=""
  89. if re.match(r'[0-9]+[ ]*-[ ]*[.]*', filename):
  90. logging.debug(f"file {filename} matched metadata regex #1")
  91. number = re.search(r'[0-9]+[ ]*-[ ]*', filename).group()
  92. title = filename.replace(number, "")
  93. number = re.sub(r'[ ]*-[ ]*', '', number)
  94. elif re.match(r'[0-9]+\.[ ]*[.]*', filename):
  95. logging.debug(f"file {filename} matched metadata regex #2")
  96. number = re.search(r'[0-9]+\.[ ]*', filename).group()
  97. title = filename.replace(number, "")
  98. number = re.sub(r'\.[ ]*', "", number)
  99. else:
  100. logging.debug(f"file {filename} matched no metadata regex")
  101. return f"-metadata TITLE=\"{title}\" -metadata TRACK=\"{number}\""
  102. def file_processor(path: str):
  103. dst_folder, preexisting = prepare_destination(path)
  104. if preexisting and not DST_OVERWRITE:
  105. logging.info(f"Album {path} already exists in output location and overwrite is disabled - skipping")
  106. return
  107. files = get_files_with_ext(path, SRC_FILE_EXTS)
  108. album_metadata = metadata_from_folder(path)
  109. for file in files:
  110. filename = get_filename(file, path)
  111. file_metadata = metadata_from_file(filename[1:])
  112. execute(f"\"{FFMPEG_LOCATION}\" -i \"{file}\" {FFMPEG_OPTS} {album_metadata} {file_metadata} \"{dst_folder + filename}.{DST_FILE_EXT}\"")
  113. # ----------------------------------------------------------------- #
  114. # Iteration over library folders and preparation
  115. def contains_extension(path: str, extensions: List[str]) -> bool:
  116. logging.debug(f"searching for extensions {extensions} in {path}")
  117. for root, dirs, files in os.walk(path):
  118. for file in files:
  119. for ext in extensions:
  120. if file.endswith(ext):
  121. logging.debug(f"file {file} matched extension '{ext}'")
  122. return True
  123. # needed to prevent recursive search
  124. break
  125. return False
  126. def get_files_with_ext(path: str, extensions: List[str]) -> List[str]:
  127. logging.debug(f"obtainint all files with extensions {extensions} from {path}")
  128. ret_files = []
  129. for root, dirs, files in os.walk(path):
  130. for file in files:
  131. for ext in extensions:
  132. if file.endswith(ext):
  133. logging.debug(f"file {file} matched extension '{ext}'")
  134. ret_files.append(os.path.join(root, file))
  135. # file is added now - no need to check the other extensions
  136. break
  137. # needed to prevent recursive search
  138. break
  139. return ret_files
  140. def prepare_destination(path: str) -> Tuple[str,bool]:
  141. images = get_files_with_ext(path, SRC_ICON_EXTS)
  142. sub_path = path.replace(SRC_FOLDER, "")
  143. new_path = DST_FOLDER + sub_path
  144. mkdir(new_path)
  145. existing: bool = len(os.listdir(new_path)) != 0
  146. for img in images:
  147. img_name = img.replace(path, "")
  148. new_img_path = new_path + img_name
  149. copy(img, new_img_path)
  150. return (new_path,existing)
  151. def get_filename(file: str, path: str) -> str:
  152. fname = file.replace(path, "")
  153. for ext in SRC_FILE_EXTS:
  154. fname = fname.replace("." + ext, "")
  155. return fname
  156. def scan_folder(path: str):
  157. for root, dirs, files in os.walk(path):
  158. for directory in dirs:
  159. process_folder(os.path.join(root, directory))
  160. # needed to prevent recursive search
  161. break
  162. def process_folder(path: str):
  163. logging.info(f"Visiting folder '{path}'")
  164. if contains_extension(path, [CUE_SHEET_EXTENSION]):
  165. logging.info("found cuesheet")
  166. cue_sheet_processor(path)
  167. elif contains_extension(path, SRC_FILE_EXTS):
  168. logging.info("found audio files")
  169. file_processor(path)
  170. else:
  171. logging.info("scanning subfolders")
  172. scan_folder(path)
  173. def execute_command_list():
  174. logging.info(f"Executing all {len(cmd_list)} commands with {THREADS} parallel threads")
  175. if DRY_RUN:
  176. logging.info("Skipping execution as we are dry-running. Printing list as debug-info.")
  177. logging.debug(str(cmd_list))
  178. return
  179. Parallel(n_jobs=THREADS)(
  180. delayed(os.system)(cmd) for cmd in cmd_list
  181. )
  182. if __name__ == "__main__":
  183. logging.info(f"Using settings from {CONFIG_FILE}")
  184. if DRY_RUN:
  185. logging.info("Executing as DRY RUN")
  186. if DST_OVERWRITE:
  187. logging.info("Overwrite of existing files active")
  188. process_folder(SRC_FOLDER)
  189. execute_command_list()