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.

232 lines
7.9 KiB

  1. # -*- coding: UTF-8 -*-
  2. """
  3. Name: ffmpeg.py
  4. Porpose: builds arguments for FFmpeg processing.
  5. Compatibility: Python3
  6. Platform: all platforms
  7. Author: Gianluca Pernigotto <jeanlucperni@gmail.com>
  8. Copyright: (c) 2022/2023 Gianluca Pernigotto <jeanlucperni@gmail.com>
  9. license: GPL3
  10. Rev: February 06 2022
  11. Code checker: flake8, pylint
  12. ########################################################
  13. This file is part of FFcuesplitter.
  14. FFcuesplitter is free software: you can redistribute it and/or modify
  15. it under the terms of the GNU General Public License as published by
  16. the Free Software Foundation, either version 3 of the License, or
  17. (at your option) any later version.
  18. FFcuesplitter is distributed in the hope that it will be useful,
  19. but WITHOUT ANY WARRANTY; without even the implied warranty of
  20. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  21. GNU General Public License for more details.
  22. You should have received a copy of the GNU General Public License
  23. along with FFcuesplitter. If not, see <http://www.gnu.org/licenses/>.
  24. """
  25. import subprocess
  26. import os
  27. import sys
  28. import platform
  29. from tqdm import tqdm
  30. from ffcuesplitter.exceptions import FFMpegError
  31. from ffcuesplitter.str_utils import msg
  32. from ffcuesplitter.utils import Popen
  33. if not platform.system() == 'Windows':
  34. import shlex
  35. class FFMpeg:
  36. """
  37. FFMpeg is a parent base class interface for FFCueSplitter.
  38. It represents FFmpeg command and arguments with their
  39. sub-processing.
  40. """
  41. DATACODECS = {'wav': 'pcm_s16le -ar 44100',
  42. 'flac': 'flac -ar 44100',
  43. 'ogg': 'libvorbis -ar 44100',
  44. 'mp3': 'libmp3lame -ar 44100',
  45. }
  46. def __init__(self, **kwargs):
  47. """
  48. Constructor
  49. """
  50. self.kwargs = kwargs
  51. self.audiotracks = kwargs
  52. self.seconds = None
  53. self.arguments = None
  54. self.osplat = platform.system()
  55. self.outsuffix = None
  56. # -------------------------------------------------------------#
  57. def codec_setup(self, sourcef):
  58. """
  59. Returns codec arg based on given format
  60. """
  61. if self.kwargs['format'] == 'copy':
  62. self.outsuffix = os.path.splitext(sourcef)[1].replace('.', '')
  63. codec = '-c copy'
  64. else:
  65. self.outsuffix = self.kwargs['format']
  66. codec = f'-c:a {FFMpeg.DATACODECS[self.kwargs["format"]]}'
  67. return codec, self.outsuffix
  68. # -------------------------------------------------------------#
  69. def ffmpeg_arguments(self):
  70. """
  71. Builds `FFmpeg` arguments and calculates time seconds
  72. for each audio track.
  73. Returns:
  74. dict(arguments:[...], seconds:[...])
  75. """
  76. self.arguments = []
  77. for track in self.audiotracks:
  78. track_arguments = []
  79. metadata = {
  80. 'ARTIST': track.get('PERFORMER', ''),
  81. 'ALBUM': track.get('ALBUM', ''),
  82. 'TITLE': track.get('TITLE', ''),
  83. 'TRACK': (str(track['TRACK_NUM']) + '/' + str(len(self.audiotracks))),
  84. 'DISCNUMBER': track.get('DISCNUMBER', ''),
  85. 'GENRE': track.get('GENRE', ''),
  86. 'DATE': track.get('DATE', ''),
  87. 'COMMENT': track.get('COMMENT', ''),
  88. 'DISCID': track.get('DISCID', ''),
  89. }
  90. fpath = os.path.join(self.kwargs["dirname"], track["FILE"])
  91. track_arguments.append('-i')
  92. track_arguments.append(f"\"{fpath}\"")
  93. track_arguments.append("-ss")
  94. track_arguments.append(f"{round(track['START'] / 44100, 6)}")
  95. if 'END' in track:
  96. track_arguments.append("-to")
  97. track_arguments.append(f" {round(track['END'] / 44100, 6)}")
  98. for key, val in metadata.items():
  99. track_arguments.append("-metadata")
  100. track_arguments.append(f'{key}="{val}"')
  101. num = str(track['TRACK_NUM']).rjust(2, '0')
  102. name = f'{num} - {track["TITLE"]}'
  103. track_arguments.append(name)
  104. self.arguments.append(track_arguments)
  105. return self.arguments
  106. # --------------------------------------------------------------#
  107. def processing(self, arg, secs):
  108. """
  109. Redirect to required processing
  110. """
  111. if self.kwargs['progress_meter'] == 'tqdm':
  112. cmd = arg if self.osplat == 'Windows' else shlex.split(arg)
  113. if self.kwargs['dry'] is True:
  114. msg(cmd) # stdout cmd in dry mode
  115. return
  116. self.processing_with_tqdm_progress(cmd, secs)
  117. elif self.kwargs['progress_meter'] == 'standard':
  118. cmd = arg if self.osplat == 'Windows' else shlex.split(arg)
  119. if self.kwargs['dry'] is True:
  120. msg(cmd) # stdout cmd in dry mode
  121. return
  122. self.processing_with_standard_progress(cmd)
  123. # --------------------------------------------------------------#
  124. def processing_with_tqdm_progress(self, cmd, seconds):
  125. """
  126. FFmpeg sub-processing showing a tqdm progress meter
  127. for each loop. Also writes a log file to the same
  128. destination folder as the .cue file .
  129. Usage for get seconds elapsed:
  130. progbar = tqdm(total=round(seconds), unit="s", dynamic_ncols=True)
  131. progbar.clear()
  132. previous_s = 0
  133. s_processed = round(int(output.split('=')[1]) / 1_000_000)
  134. s_increase = s_processed - previous_s
  135. progbar.update(s_increase)
  136. previous_s = s_processed
  137. Raises:
  138. FFMpegError
  139. Returns:
  140. None
  141. """
  142. progbar = tqdm(total=100,
  143. unit="s",
  144. dynamic_ncols=True
  145. )
  146. progbar.clear()
  147. try:
  148. with open(self.kwargs['logtofile'], "w", encoding='utf-8') as log:
  149. log.write(f'\nCOMMAND: {cmd}')
  150. with Popen(cmd,
  151. stdout=subprocess.PIPE,
  152. stderr=log,
  153. bufsize=1,
  154. universal_newlines=True) as proc:
  155. for output in proc.stdout:
  156. if "out_time_ms" in output.strip():
  157. s_processed = int(output.split('=')[1]) / 1_000_000
  158. percent = s_processed / seconds * 100
  159. progbar.update(round(percent) - progbar.n)
  160. if proc.wait(): # error
  161. progbar.close()
  162. raise FFMpegError(f"ffmpeg FAILED: See log details: "
  163. f"'{self.kwargs['logtofile']}'"
  164. f"\nExit status: {proc.wait()}")
  165. except (OSError, FileNotFoundError) as excepterr:
  166. progbar.close()
  167. raise FFMpegError(excepterr) from excepterr
  168. except KeyboardInterrupt:
  169. # proc.kill()
  170. progbar.close()
  171. proc.terminate()
  172. sys.exit("\n[KeyboardInterrupt] FFmpeg process terminated.")
  173. progbar.close()
  174. # --------------------------------------------------------------#
  175. def processing_with_standard_progress(self, cmd):
  176. """
  177. FFmpeg sub-processing with stderr output to console.
  178. The output depending on the ffmpeg loglevel option.
  179. Raises:
  180. FFMpegError
  181. Returns:
  182. None
  183. """
  184. with open(self.kwargs['logtofile'], "w", encoding='utf-8') as log:
  185. log.write(f'COMMAND: {cmd}')
  186. try:
  187. subprocess.run(cmd, check=True, shell=False)
  188. except FileNotFoundError as err:
  189. raise FFMpegError(f"{err}") from err
  190. except subprocess.CalledProcessError as err:
  191. raise FFMpegError(f"ffmpeg FAILED: {err}") from err
  192. except KeyboardInterrupt:
  193. sys.exit("\n[KeyboardInterrupt]")