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.

353 lines
13 KiB

  1. """
  2. First release: January 16 2022
  3. Name: cuesplitter.py
  4. Porpose: FFmpeg based audio splitter for Cue sheet files
  5. Platform: MacOs, Gnu/Linux, FreeBSD
  6. Writer: jeanslack <jeanlucperni@gmail.com>
  7. license: GPL3
  8. Rev: February 06 2022
  9. Code checker: flake8 and pylint
  10. ####################################################################
  11. This file is part of FFcuesplitter.
  12. FFcuesplitter is free software: you can redistribute it and/or modify
  13. it under the terms of the GNU General Public License as published by
  14. the Free Software Foundation, either version 3 of the License, or
  15. (at your option) any later version.
  16. FFcuesplitter is distributed in the hope that it will be useful,
  17. but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. GNU General Public License for more details.
  20. You should have received a copy of the GNU General Public License
  21. along with FFcuesplitter. If not, see <http://www.gnu.org/licenses/>.
  22. """
  23. import os
  24. import shutil
  25. import tempfile
  26. import chardet
  27. from deflacue.deflacue import CueParser
  28. from ffcuesplitter.str_utils import msgdebug, msg
  29. from ffcuesplitter.exceptions import (InvalidFileError,
  30. FFCueSplitterError,
  31. )
  32. from ffcuesplitter.ffprobe import ffprobe
  33. from ffcuesplitter.ffmpeg import FFMpeg
  34. class FFCueSplitter(FFMpeg):
  35. """
  36. This class implements an interface for retrieve the required
  37. data to accurately split audio CD images using FFmpeg.
  38. Usage:
  39. >>> from ffcuesplitter.cuesplitter import FFCueSplitter
  40. Splittings:
  41. >>> split = FFCueSplitter('/home/user/my_file.cue')
  42. >>> split.open_cuefile()
  43. >>> split.do_operations()
  44. Get data tracks:
  45. >>> data = FFCueSplitter('/home/user/other.cue', dry=True)
  46. >>> data.open_cuefile()
  47. >>> data.audiotracks # trackdata
  48. >>> data.cue.meta.data # cd_info
  49. >>> data.ffmpeg_arguments()
  50. Only processing the track three:
  51. >>> myfile = FFCueSplitter('/home/user/my_file.cue',
  52. progress_meter='tqdm')
  53. >>> f.open_cuefile()
  54. >>> f.kwargs['tempdir'] = '/tmp/mytempdir'
  55. >>> f.ffmpeg_arguments()
  56. >>> f.processing(myfile.arguments[2], myfile.seconds[2])
  57. >>> f.move_files_to_outputdir()
  58. For a full meaning of the arguments to pass to the instance, read
  59. the __init__ docstring of this class.
  60. """
  61. def __init__(self,
  62. filename,
  63. outputdir=str('.'),
  64. suffix=str('flac'),
  65. overwrite=str('ask'),
  66. ffmpeg_cmd=str('ffmpeg'),
  67. ffmpeg_loglevel=str('info'),
  68. ffprobe_cmd=str('ffprobe'),
  69. ffmpeg_add_params=str(''),
  70. progress_meter=str('standard'),
  71. dry=bool(False)
  72. ):
  73. """
  74. ------------------
  75. Arguments meaning:
  76. ------------------
  77. filename:
  78. absolute or relative CUE sheet file
  79. outputdir:
  80. absolute or relative pathname to output files
  81. suffix:
  82. output format, one of ("wav", "flac", "mp3", "ogg") .
  83. overwrite:
  84. overwriting options, one of "ask", "never", "always".
  85. ffmpeg_cmd:
  86. an absolute path command of ffmpeg
  87. ffmpeg_loglevel:
  88. one of "error", "warning", "info", "verbose", "debug" .
  89. ffprobe_cmd:
  90. an absolute path command of ffprobe.
  91. ffmpeg_add_params:
  92. additionals parameters of FFmpeg.
  93. progress_meter:
  94. one of 'tqdm', 'standard', default is 'standard'.
  95. dry:
  96. with `True`, perform the dry run with no changes
  97. done to filesystem.
  98. """
  99. super().__init__()
  100. self.kwargs = {'filename': os.path.abspath(filename)}
  101. self.kwargs['dirname'] = os.path.dirname(self.kwargs['filename'])
  102. if outputdir == '.':
  103. self.kwargs['outputdir'] = self.kwargs['dirname']
  104. else:
  105. self.kwargs['outputdir'] = os.path.abspath(outputdir)
  106. self.kwargs['format'] = suffix
  107. self.kwargs['overwrite'] = overwrite
  108. self.kwargs['ffmpeg_cmd'] = ffmpeg_cmd
  109. self.kwargs['ffmpeg_loglevel'] = ffmpeg_loglevel
  110. self.kwargs['ffprobe_cmd'] = ffprobe_cmd
  111. self.kwargs['ffmpeg_add_params'] = ffmpeg_add_params
  112. self.kwargs['progress_meter'] = progress_meter
  113. self.kwargs['dry'] = dry
  114. self.kwargs['logtofile'] = os.path.join(self.kwargs['dirname'],
  115. 'ffcuesplitter.log')
  116. self.kwargs['tempdir'] = '.'
  117. self.audiotracks = None
  118. self.probedata = []
  119. self.cue_encoding = None # data chardet
  120. self.cue = None
  121. self.testpatch = None # set for test cases only
  122. # ----------------------------------------------------------------#
  123. def move_files_to_outputdir(self):
  124. """
  125. All files are processed in a /temp folder. After the split
  126. operation is complete, all tracks are moved from /temp folder
  127. to output folder. Here evaluates what to do if files already
  128. exists on output folder.
  129. Raises:
  130. FFCueSplitterError
  131. Returns:
  132. None
  133. """
  134. outputdir = self.kwargs['outputdir']
  135. overwr = self.kwargs['overwrite']
  136. for track in os.listdir(self.kwargs['tempdir']):
  137. if os.path.exists(os.path.join(outputdir, track)):
  138. if overwr in ('n', 'N', 'y', 'Y', 'ask'):
  139. while True:
  140. msgdebug(warn=f"File already exists: "
  141. f"'{os.path.join(outputdir, track)}'")
  142. overwr = input("Overwrite [Y/n/always/never]? > ")
  143. if overwr in ('Y', 'y', 'n', 'N', 'always', 'never'):
  144. break
  145. msgdebug(err=f"Invalid option '{overwr}'")
  146. continue
  147. if overwr == 'never':
  148. msgdebug(info=("Do not overwrite any files because "
  149. "you specified 'never' option"))
  150. return None
  151. if overwr in ('y', 'Y', 'always', 'never', 'ask'):
  152. if overwr == 'always':
  153. msgdebug(info=("Overwrite existing file because "
  154. "you specified the 'always' option"))
  155. try:
  156. shutil.move(os.path.join(self.kwargs['tempdir'], track),
  157. os.path.join(outputdir, track))
  158. except Exception as error:
  159. raise FFCueSplitterError(error) from error
  160. return None
  161. # ----------------------------------------------------------------#
  162. def do_operations(self):
  163. """
  164. Automates the work in a temporary context using tempfile.
  165. Raises:
  166. FFCueSplitterError
  167. Returns:
  168. None
  169. """
  170. with tempfile.TemporaryDirectory(suffix=None,
  171. prefix='ffcuesplitter_',
  172. dir=None) as tmpdir:
  173. self.kwargs['tempdir'] = tmpdir
  174. self.ffmpeg_arguments()
  175. msgdebug(info=(f"Temporary Target: '{self.kwargs['tempdir']}'"))
  176. count = 0
  177. msgdebug(info="Extracting audio tracks (type Ctrl+c to stop):")
  178. for args, secs, title in zip(self.arguments,
  179. self.seconds,
  180. self.audiotracks):
  181. count += 1
  182. msg(f'\nTRACK {count}/{len(self.audiotracks)} '
  183. f'>> "{title["TITLE"]}.{self.outsuffix}" ...')
  184. self.processing(args, secs)
  185. if self.kwargs['dry'] is True:
  186. return
  187. msg('\n')
  188. msgdebug(info="...done exctracting")
  189. msgdebug(info="Move files to: ",
  190. tail=(f"\033[34m"
  191. f"'{os.path.abspath(self.kwargs['outputdir'])}'"
  192. f"\033[0m"))
  193. try:
  194. os.makedirs(self.kwargs['outputdir'],
  195. mode=0o777, exist_ok=True)
  196. except Exception as error:
  197. raise FFCueSplitterError(error) from error
  198. self.move_files_to_outputdir()
  199. # ----------------------------------------------------------------#
  200. def get_track_duration(self, tracks):
  201. """
  202. Gets total duration of the source audio tracks for chunks
  203. calculation on the progress meter during ffmpeg executions.
  204. Given a total duration calculates the remains duration
  205. for the last track as well.
  206. This method is called by `cuefile_parser` method, Do not
  207. call this method directly.
  208. Raises:
  209. FFCueSplitterError
  210. Returns:
  211. tracks (list), all track data taken from the cue file.
  212. """
  213. if self.testpatch:
  214. probe = {'format': {'duration': 6.000000}}
  215. else:
  216. filename = tracks[0].get('FILE')
  217. cmd = self.kwargs['ffprobe_cmd']
  218. kwargs = {'loglevel': 'error', 'hide_banner': None}
  219. probe = ffprobe(filename, cmd=cmd, **kwargs)
  220. self.probedata.append(probe)
  221. time = []
  222. for idx in enumerate(tracks):
  223. if idx[0] != len(tracks) - 1: # minus last
  224. trk = (tracks[idx[0] + 1]['START'] -
  225. tracks[idx[0]]['START']) / (44100)
  226. time.append(trk)
  227. if not time:
  228. last = (float(probe['format']['duration']) -
  229. tracks[0]['START'] / 44100)
  230. else:
  231. last = float(probe['format']['duration']) - sum(time)
  232. time.append(last)
  233. for keydur, remain in zip(tracks, time):
  234. keydur['DURATION'] = remain
  235. return tracks
  236. # ----------------------------------------------------------------#
  237. def deflacue_object_handler(self):
  238. """
  239. Handles `deflacue.CueParser` data.
  240. Raises:
  241. FFCueSplitterError: if no source audio file found
  242. Returns:
  243. 'audiotracks' list object
  244. """
  245. self.audiotracks = []
  246. cd_info = self.cue.meta.data
  247. def sanitize(val: str) -> str:
  248. return val.replace('/', '').replace('\\','').replace('"', '')
  249. tracks = self.cue.tracks
  250. sourcenames = {k: [] for k in [str(x.file.path) for x in tracks]}
  251. for track in enumerate(tracks):
  252. track_file = track[1].file.path
  253. if not track_file.exists():
  254. msgdebug(warn=(f'Source file `{track_file}` is not '
  255. f'found. Track is skipped.'))
  256. if str(track_file) in sourcenames:
  257. sourcenames.pop(str(track_file))
  258. if not sourcenames:
  259. raise FFCueSplitterError('No audio source files '
  260. 'found!')
  261. continue
  262. filename = (f"{sanitize(track[1].title)}")
  263. data = {'FILE': str(track_file), **cd_info, **track[1].data}
  264. data['TITLE'] = filename
  265. data['START'] = track[1].start
  266. if track[1].end != 0:
  267. data['END'] = track[1].end
  268. if f"{data['FILE']}" in sourcenames.keys():
  269. sourcenames[f'{data["FILE"]}'].append(data)
  270. for val in sourcenames.values():
  271. self.audiotracks += self.get_track_duration(val)
  272. return self.audiotracks
  273. # ----------------------------------------------------------------#
  274. def check_cuefile(self):
  275. """
  276. Cue file check
  277. """
  278. filesuffix = os.path.splitext(self.kwargs['filename'])[1]
  279. isfile = os.path.isfile(self.kwargs['filename'])
  280. if not isfile or filesuffix not in ('.cue', '.CUE'):
  281. raise InvalidFileError(f"Invalid CUE sheet file: "
  282. f"'{self.kwargs['filename']}'")
  283. # ----------------------------------------------------------------#
  284. def open_cuefile(self, testpatch=None):
  285. """
  286. Read cue file and start file parsing via deflacue package
  287. """
  288. if testpatch:
  289. self.testpatch = True
  290. self.check_cuefile()
  291. curdir = os.getcwd()
  292. os.chdir(self.kwargs['dirname'])
  293. with open(self.kwargs['filename'], 'rb') as file:
  294. cuebyte = file.read()
  295. self.cue_encoding = chardet.detect(cuebyte)
  296. parser = CueParser.from_file(self.kwargs['filename'],
  297. encoding=self.cue_encoding['encoding'])
  298. self.cue = parser.run()
  299. self.deflacue_object_handler()
  300. os.chdir(curdir)