ffmpeg-normalize 1.36.0__py3-none-any.whl → 1.37.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import importlib.metadata
2
2
 
3
+ from ._cmd_utils import ffmpeg_env
3
4
  from ._errors import FFmpegNormalizeError
4
5
  from ._ffmpeg_normalize import FFmpegNormalize
5
6
  from ._media_file import MediaFile
@@ -17,5 +18,6 @@ __all__ = [
17
18
  "VideoStream",
18
19
  "SubtitleStream",
19
20
  "MediaStream",
21
+ "ffmpeg_env",
20
22
  "__version__",
21
23
  ]
@@ -723,6 +723,18 @@ def main() -> None:
723
723
  if not input_files:
724
724
  error("No input files specified. Use positional arguments or --input-list.")
725
725
 
726
+ # Validate all input files upfront before processing
727
+ _logger.debug("Validating all input files before processing...")
728
+ validation_errors = FFmpegNormalize.validate_input_files(input_files)
729
+ if validation_errors:
730
+ _logger.error("Validation failed for the following files:")
731
+ for err in validation_errors:
732
+ _logger.error(f" - {err}")
733
+ error(
734
+ f"Validation failed for {len(validation_errors)} file(s). "
735
+ "Please fix the issues above and try again."
736
+ )
737
+
726
738
  for index, input_file in enumerate(input_files):
727
739
  if cli_args.output is not None and index < len(cli_args.output):
728
740
  if cli_args.output_folder and cli_args.output_folder != "normalized":
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextvars
3
4
  import logging
4
5
  import os
5
6
  import re
6
7
  import shlex
7
8
  import subprocess
9
+ from contextlib import contextmanager
8
10
  from shutil import which
9
11
  from typing import Any, Iterator
10
12
 
@@ -14,6 +16,30 @@ from ._errors import FFmpegNormalizeError
14
16
 
15
17
  _logger = logging.getLogger(__name__)
16
18
 
19
+ _ffmpeg_env_var: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar(
20
+ "ffmpeg_env", default=None
21
+ )
22
+
23
+
24
+ @contextmanager
25
+ def ffmpeg_env(env: dict[str, str] | None) -> Iterator[None]:
26
+ """
27
+ Temporarily set the environment for subprocess.Popen.
28
+
29
+ Args:
30
+ env: Environment dict to pass to subprocess.Popen.
31
+ """
32
+ token = _ffmpeg_env_var.set(env)
33
+ try:
34
+ yield
35
+ finally:
36
+ _ffmpeg_env_var.reset(token)
37
+
38
+
39
+ def _get_ffmpeg_env() -> dict[str, str] | None:
40
+ return _ffmpeg_env_var.get()
41
+
42
+
17
43
  DUR_REGEX = re.compile(
18
44
  r"Duration: (?P<hour>\d{2}):(?P<min>\d{2}):(?P<sec>\d{2})\.(?P<ms>\d{2})"
19
45
  )
@@ -76,7 +102,9 @@ class CommandRunner:
76
102
  # wrapper for 'ffmpeg-progress-yield'
77
103
  _logger.debug(f"Running command: {shlex.join(cmd)}")
78
104
  with FfmpegProgress(cmd, dry_run=self.dry) as ff:
79
- yield from ff.run_command_with_progress()
105
+ yield from ff.run_command_with_progress(
106
+ popen_kwargs={"env": _get_ffmpeg_env()}
107
+ )
80
108
 
81
109
  self.output = ff.stderr
82
110
 
@@ -107,6 +135,7 @@ class CommandRunner:
107
135
  stdout=subprocess.PIPE,
108
136
  stderr=subprocess.PIPE,
109
137
  universal_newlines=False,
138
+ env=_get_ffmpeg_env(),
110
139
  )
111
140
 
112
141
  stdout_bytes, stderr_bytes = p.communicate()
@@ -190,3 +219,76 @@ def ffmpeg_has_loudnorm() -> bool:
190
219
  "Please make sure you are running ffmpeg v4.2 or above."
191
220
  )
192
221
  return supports_loudnorm
222
+
223
+
224
+ def validate_input_file(input_file: str) -> tuple[bool, str | None]:
225
+ """
226
+ Validate that an input file exists, is readable, and contains audio streams.
227
+
228
+ This function performs a lightweight probe of the file using ffmpeg to check
229
+ if it can be read and contains at least one audio stream.
230
+
231
+ Args:
232
+ input_file: Path to the input file to validate
233
+
234
+ Returns:
235
+ tuple: (is_valid, error_message)
236
+ - is_valid: True if the file is valid, False otherwise
237
+ - error_message: None if valid, otherwise a descriptive error message
238
+ """
239
+ # Check if file exists
240
+ if not os.path.exists(input_file):
241
+ return False, f"File does not exist: {input_file}"
242
+
243
+ # Check if it's actually a file (not a directory)
244
+ if not os.path.isfile(input_file):
245
+ return False, f"Path is not a file: {input_file}"
246
+
247
+ # Check if file is readable
248
+ if not os.access(input_file, os.R_OK):
249
+ return False, f"File is not readable (permission denied): {input_file}"
250
+
251
+ # Check if file has audio streams using ffmpeg probe
252
+ ffmpeg_exe = get_ffmpeg_exe()
253
+ cmd = [
254
+ ffmpeg_exe,
255
+ "-i",
256
+ input_file,
257
+ "-c",
258
+ "copy",
259
+ "-t",
260
+ "0",
261
+ "-map",
262
+ "0",
263
+ "-f",
264
+ "null",
265
+ os.devnull,
266
+ ]
267
+
268
+ try:
269
+ output = CommandRunner().run_command(cmd).get_output()
270
+ except RuntimeError as e:
271
+ error_str = str(e)
272
+ # Extract a cleaner error message from ffmpeg output
273
+ if "Invalid data found" in error_str:
274
+ return False, f"Invalid or corrupted media file: {input_file}"
275
+ if "No such file or directory" in error_str:
276
+ return False, f"File not found or cannot be opened: {input_file}"
277
+ if "Permission denied" in error_str:
278
+ return False, f"Permission denied when reading file: {input_file}"
279
+ if "does not contain any stream" in error_str:
280
+ return False, f"File contains no media streams: {input_file}"
281
+ # Generic error for other ffmpeg failures
282
+ return False, f"Cannot read media file: {input_file}"
283
+
284
+ # Check for audio streams in the output
285
+ has_audio = False
286
+ for line in output.split("\n"):
287
+ if line.strip().startswith("Stream") and "Audio" in line:
288
+ has_audio = True
289
+ break
290
+
291
+ if not has_audio:
292
+ return False, f"File does not contain any audio streams: {input_file}"
293
+
294
+ return True, None
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Literal
9
9
 
10
10
  from tqdm import tqdm
11
11
 
12
- from ._cmd_utils import ffmpeg_has_loudnorm, get_ffmpeg_exe
12
+ from ._cmd_utils import ffmpeg_has_loudnorm, get_ffmpeg_exe, validate_input_file
13
13
  from ._errors import FFmpegNormalizeError
14
14
  from ._media_file import MediaFile
15
15
 
@@ -317,6 +317,28 @@ class FFmpegNormalize:
317
317
  self.media_files.append(MediaFile(self, input_file, output_file))
318
318
  self.file_count += 1
319
319
 
320
+ @staticmethod
321
+ def validate_input_files(input_files: list[str]) -> list[str]:
322
+ """
323
+ Validate all input files before processing.
324
+
325
+ This method checks that each input file exists, is readable, and contains
326
+ at least one audio stream. All files are validated upfront so that users
327
+ can fix all issues before rerunning the batch.
328
+
329
+ Args:
330
+ input_files: List of input file paths to validate
331
+
332
+ Returns:
333
+ list: List of error messages for invalid files. Empty if all files are valid.
334
+ """
335
+ errors = []
336
+ for input_file in input_files:
337
+ is_valid, error_msg = validate_input_file(input_file)
338
+ if not is_valid and error_msg:
339
+ errors.append(error_msg)
340
+ return errors
341
+
320
342
  def _calculate_batch_reference(self) -> float | None:
321
343
  """
322
344
  Calculate the batch reference loudness by averaging measurements across all files.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffmpeg-normalize
3
- Version: 1.36.0
3
+ Version: 1.37.0
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -37,7 +37,7 @@ Description-Content-Type: text/markdown
37
37
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/slhck/ffmpeg-normalize/python-package.yml)
38
38
 
39
39
  <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
40
- [![All Contributors](https://img.shields.io/badge/all_contributors-22-orange.svg?style=flat-square)](#contributors-)
40
+ [![All Contributors](https://img.shields.io/badge/all_contributors-23-orange.svg?style=flat-square)](#contributors-)
41
41
  <!-- ALL-CONTRIBUTORS-BADGE:END -->
42
42
 
43
43
  A utility for batch-normalizing audio using ffmpeg.
@@ -54,7 +54,14 @@ This program normalizes media files to a certain loudness level using the EBU R1
54
54
  - Docker support — Run via Docker container
55
55
  - Python API — Use programmatically in your Python projects
56
56
  - Shell completions — Available for bash, zsh, and fish
57
- - Album Batch normalization – Process files jointy, preserving relative loudness
57
+ - Album Batch normalization – Process files jointly, preserving relative loudness
58
+
59
+ ## 🚀 Quick Start
60
+
61
+ 1. Install a recent version of [ffmpeg](https://ffmpeg.org/download.html)
62
+ 2. Run `pip3 install ffmpeg-normalize` and `ffmpeg-normalize /path/to/your/file.mp4`, alternatively install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) and run `uvx ffmpeg-normalize /path/to/your/file.mp4`
63
+ 3. Done! 🎧 (the normalized file will be called `normalized/file.mkv`)
64
+
58
65
 
59
66
  ## 🆕 What's New
60
67
 
@@ -99,12 +106,6 @@ Other recent additions:
99
106
 
100
107
  See the [full changelog](https://github.com/slhck/ffmpeg-normalize/blob/master/CHANGELOG.md) for all updates.
101
108
 
102
- ## 🚀 Quick Start
103
-
104
- 1. Install a recent version of [ffmpeg](https://ffmpeg.org/download.html)
105
- 2. Run `pip3 install ffmpeg-normalize` and `ffmpeg-normalize /path/to/your/file.mp4`, alternatively install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) and run `uvx ffmpeg-normalize /path/to/your/file.mp4`
106
- 3. Done! 🎧 (the normalized file will be called `normalized/file.mkv`)
107
-
108
109
  ## 📓 Documentation
109
110
 
110
111
  Check out our [documentation](https://slhck.info/ffmpeg-normalize/) for more info!
@@ -147,6 +148,7 @@ The only reason this project exists in its current form is because [@benjaoming]
147
148
  </tr>
148
149
  <tr>
149
150
  <td align="center" valign="top" width="14.28%"><a href="https://davidbern.com/"><img src="https://avatars.githubusercontent.com/u/371066?v=4?s=100" width="100px;" alt="David Bern"/><br /><sub><b>David Bern</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=odie5533" title="Code">💻</a></td>
151
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/randompersona1"><img src="https://avatars.githubusercontent.com/u/74961116?v=4?s=100" width="100px;" alt="randompersona1"/><br /><sub><b>randompersona1</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=randompersona1" title="Code">💻</a></td>
150
152
  </tr>
151
153
  </tbody>
152
154
  <tfoot>
@@ -1,8 +1,8 @@
1
- ffmpeg_normalize/__init__.py,sha256=l0arjiMMBNbiH3IH67gT6SdZjPGAVLAdorUx38dNtvE,508
2
- ffmpeg_normalize/__main__.py,sha256=4LgOHFwZS0lVcdYptNX4CCPDtG16yfMgUBjgom74FWg,25830
3
- ffmpeg_normalize/_cmd_utils.py,sha256=1JspVpguAPsq7DqvyvjUNzHhVv8J3X93xNOMwito_jY,5284
1
+ ffmpeg_normalize/__init__.py,sha256=FXJzAZSSg7CTNyicBcY7cOZVnhBsMET80SLdwvzvsY8,561
2
+ ffmpeg_normalize/__main__.py,sha256=U77hAgn7_w2QErUEocf6tal-7v-lJR7GbUQW8L-FmC0,26357
3
+ ffmpeg_normalize/_cmd_utils.py,sha256=A4cFo7sUccV_2OL2yHjZmocvVOj7nf5fiD_6Yw9gSPk,8450
4
4
  ffmpeg_normalize/_errors.py,sha256=brTQ4osJ4fTA8wnyMPVVYfGwJ0wqeShRFydTEwi_VEY,48
5
- ffmpeg_normalize/_ffmpeg_normalize.py,sha256=jCiYhXeV3u8e7sJKOOnGYN5X7stcLv9eg_h_pR-1olM,21372
5
+ ffmpeg_normalize/_ffmpeg_normalize.py,sha256=tcO_nJvsNiucsCJJcMESYsb8GbThcyhniIl21JmxNn0,22190
6
6
  ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
7
7
  ffmpeg_normalize/_media_file.py,sha256=awznS5C8ph6Mjy5dzwT-ubBpB3MXwbO94QFcn7mBejY,32197
8
8
  ffmpeg_normalize/_presets.py,sha256=AffqFzAHvy0GxCuJl_qd3xknncPJUQx7OXbSfYAw2xg,8852
@@ -11,8 +11,8 @@ ffmpeg_normalize/data/presets/music.json,sha256=rKtIKAD7jlMvnGRsx6QAyPHvrGu-Ecq4
11
11
  ffmpeg_normalize/data/presets/podcast.json,sha256=spMBZ_tbfU81NF6o7ZUMyQmQ7kPz3jJ1GKH-hcRmt4s,132
12
12
  ffmpeg_normalize/data/presets/streaming-video.json,sha256=Uy4QR0kSla4vXEqctZj26-8f_eS4SQxQWQ9zI_x5QBw,132
13
13
  ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- ffmpeg_normalize-1.36.0.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
15
- ffmpeg_normalize-1.36.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
16
- ffmpeg_normalize-1.36.0.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
17
- ffmpeg_normalize-1.36.0.dist-info/METADATA,sha256=hXTuiFMbjXW1T2R9n9PPs8n9lF4i3JQfNbIUhPSbRpE,13933
18
- ffmpeg_normalize-1.36.0.dist-info/RECORD,,
14
+ ffmpeg_normalize-1.37.0.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
15
+ ffmpeg_normalize-1.37.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
16
+ ffmpeg_normalize-1.37.0.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
17
+ ffmpeg_normalize-1.37.0.dist-info/METADATA,sha256=SlCoUapWTSJjvsLBcVgxKagK2nHdqfEtaMjbfo-ZT-o,14296
18
+ ffmpeg_normalize-1.37.0.dist-info/RECORD,,