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.

230 lines
8.3 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. self.seconds = []
  78. meters = {'tqdm': '-progress pipe:1 -nostats -nostdin', 'standard': ''}
  79. for track in self.audiotracks:
  80. codec, suffix = self.codec_setup(track["FILE"])
  81. metadata = {'ARTIST': track.get('PERFORMER', ''),
  82. 'ALBUM': track.get('ALBUM', ''),
  83. 'TITLE': track.get('TITLE', ''),
  84. 'TRACK': (str(track['TRACK_NUM']) + '/' +
  85. str(len(self.audiotracks))),
  86. 'DISCNUMBER': track.get('DISCNUMBER', ''),
  87. 'GENRE': track.get('GENRE', ''),
  88. 'DATE': track.get('DATE', ''),
  89. 'COMMENT': track.get('COMMENT', ''),
  90. 'DISCID': track.get('DISCID', ''),
  91. }
  92. cmd = f'"{self.kwargs["ffmpeg_cmd"]}" '
  93. cmd += f' -loglevel {self.kwargs["ffmpeg_loglevel"]}'
  94. cmd += f" {meters[self.kwargs['progress_meter']]}"
  95. fpath = os.path.join(self.kwargs["dirname"], track["FILE"])
  96. cmd += f' -i "{fpath}"'
  97. cmd += f" -ss {round(track['START'] / 44100, 6)}" # ff to secs
  98. if 'END' in track:
  99. cmd += f" -to {round(track['END'] / 44100, 6)}" # ff to secs
  100. for key, val in metadata.items():
  101. cmd += f' -metadata {key}="{val}"'
  102. cmd += f' {codec}'
  103. cmd += f" {self.kwargs['ffmpeg_add_params']}"
  104. cmd += ' -y'
  105. num = str(track['TRACK_NUM']).rjust(2, '0')
  106. name = f'{num} - {track["TITLE"]}.{suffix}'
  107. cmd += f' "{os.path.join(self.kwargs["tempdir"], name)}"'
  108. self.arguments.append(cmd)
  109. self.seconds.append(track['DURATION'])
  110. return {'arguments': self.arguments, 'seconds': self.seconds}
  111. # --------------------------------------------------------------#
  112. def processing(self, arg, secs):
  113. """
  114. Redirect to required processing
  115. """
  116. if self.kwargs['progress_meter'] == 'tqdm':
  117. cmd = arg if self.osplat == 'Windows' else shlex.split(arg)
  118. if self.kwargs['dry'] is True:
  119. msg(cmd) # stdout cmd in dry mode
  120. return
  121. self.processing_with_tqdm_progress(cmd, secs)
  122. elif self.kwargs['progress_meter'] == 'standard':
  123. cmd = arg if self.osplat == 'Windows' else shlex.split(arg)
  124. if self.kwargs['dry'] is True:
  125. msg(cmd) # stdout cmd in dry mode
  126. return
  127. self.processing_with_standard_progress(cmd)
  128. # --------------------------------------------------------------#
  129. def processing_with_tqdm_progress(self, cmd, seconds):
  130. """
  131. FFmpeg sub-processing showing a tqdm progress meter
  132. for each loop. Also writes a log file to the same
  133. destination folder as the .cue file .
  134. Usage for get seconds elapsed:
  135. progbar = tqdm(total=round(seconds), unit="s", dynamic_ncols=True)
  136. progbar.clear()
  137. previous_s = 0
  138. s_processed = round(int(output.split('=')[1]) / 1_000_000)
  139. s_increase = s_processed - previous_s
  140. progbar.update(s_increase)
  141. previous_s = s_processed
  142. Raises:
  143. FFMpegError
  144. Returns:
  145. None
  146. """
  147. progbar = tqdm(total=100,
  148. unit="s",
  149. dynamic_ncols=True
  150. )
  151. progbar.clear()
  152. try:
  153. with open(self.kwargs['logtofile'], "w", encoding='utf-8') as log:
  154. log.write(f'\nCOMMAND: {cmd}')
  155. with Popen(cmd,
  156. stdout=subprocess.PIPE,
  157. stderr=log,
  158. bufsize=1,
  159. universal_newlines=True) as proc:
  160. for output in proc.stdout:
  161. if "out_time_ms" in output.strip():
  162. s_processed = int(output.split('=')[1]) / 1_000_000
  163. percent = s_processed / seconds * 100
  164. progbar.update(round(percent) - progbar.n)
  165. if proc.wait(): # error
  166. progbar.close()
  167. raise FFMpegError(f"ffmpeg FAILED: See log details: "
  168. f"'{self.kwargs['logtofile']}'"
  169. f"\nExit status: {proc.wait()}")
  170. except (OSError, FileNotFoundError) as excepterr:
  171. progbar.close()
  172. raise FFMpegError(excepterr) from excepterr
  173. except KeyboardInterrupt:
  174. # proc.kill()
  175. progbar.close()
  176. proc.terminate()
  177. sys.exit("\n[KeyboardInterrupt] FFmpeg process terminated.")
  178. progbar.close()
  179. # --------------------------------------------------------------#
  180. def processing_with_standard_progress(self, cmd):
  181. """
  182. FFmpeg sub-processing with stderr output to console.
  183. The output depending on the ffmpeg loglevel option.
  184. Raises:
  185. FFMpegError
  186. Returns:
  187. None
  188. """
  189. with open(self.kwargs['logtofile'], "w", encoding='utf-8') as log:
  190. log.write(f'COMMAND: {cmd}')
  191. try:
  192. subprocess.run(cmd, check=True, shell=False)
  193. except FileNotFoundError as err:
  194. raise FFMpegError(f"{err}") from err
  195. except subprocess.CalledProcessError as err:
  196. raise FFMpegError(f"ffmpeg FAILED: {err}") from err
  197. except KeyboardInterrupt:
  198. sys.exit("\n[KeyboardInterrupt]")