ffmpeg-normalize 1.37.1__tar.gz → 1.37.3__tar.gz

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.
Files changed (17) hide show
  1. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/PKG-INFO +1 -1
  2. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/pyproject.toml +1 -1
  3. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/__main__.py +10 -6
  4. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/_media_file.py +101 -2
  5. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/_presets.py +31 -0
  6. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/LICENSE.md +0 -0
  7. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/README.md +0 -0
  8. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/__init__.py +0 -0
  9. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/_cmd_utils.py +0 -0
  10. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/_errors.py +0 -0
  11. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/_ffmpeg_normalize.py +0 -0
  12. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/_logger.py +0 -0
  13. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/_streams.py +0 -0
  14. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/data/presets/music.json +0 -0
  15. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
  16. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
  17. {ffmpeg_normalize-1.37.1 → ffmpeg_normalize-1.37.3}/src/ffmpeg_normalize/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffmpeg-normalize
3
- Version: 1.37.1
3
+ Version: 1.37.3
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.37.1"
7
+ version = "1.37.3"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -197,7 +197,7 @@ def create_parser() -> argparse.ArgumentParser:
197
197
  help=textwrap.dedent(
198
198
  """\
199
199
  Write ReplayGain tags to the original file without normalizing.
200
- This mode will overwrite the input file and ignore other options.
200
+ This mode will overwrite the input file and ignore other options. It will strip all ReplayGain tags.
201
201
  Only works with EBU normalization, and only with .mp3, .mp4/.m4a, .ogg, .opus for now.
202
202
  """
203
203
  ),
@@ -607,13 +607,17 @@ def main() -> None:
607
607
  # Handle --list-presets
608
608
  preset_manager = PresetManager()
609
609
  if cli_args.list_presets:
610
- presets = preset_manager.get_available_presets()
611
- if presets:
610
+ presets_with_source = preset_manager.get_presets_with_source()
611
+ if presets_with_source:
612
612
  print("Available presets:")
613
- for preset in presets:
614
- print(f" - {preset}")
613
+ for preset_name, source in presets_with_source:
614
+ source_label = "(user)" if source == "user" else "(builtin)"
615
+ print(f" - {preset_name} {source_label}")
615
616
  else:
616
- print(f"No presets found in {preset_manager.presets_dir}")
617
+ print("No presets found.")
618
+ print()
619
+ print(f"User presets directory: {preset_manager.presets_dir}")
620
+ print("Place custom .json preset files in this directory.")
617
621
  sys.exit(0)
618
622
 
619
623
  # Load and apply preset if specified
@@ -332,8 +332,10 @@ class MediaFile:
332
332
  "ReplayGain tagging is enabled. Proceeding with tag calculation/application."
333
333
  )
334
334
  self._run_replaygain()
335
-
336
- if not self.ffmpeg_normalize.replaygain:
335
+ else:
336
+ # Strip any existing ReplayGain tags from the output file
337
+ # since they are no longer accurate after normalization
338
+ self._strip_replaygain_tags(self.output_file)
337
339
  _logger.info(f"Normalized file written to {self.output_file}")
338
340
 
339
341
  def _run_replaygain(self) -> None:
@@ -461,6 +463,103 @@ class MediaFile:
461
463
  f"Successfully wrote replaygain tags to input file {self.input_file}"
462
464
  )
463
465
 
466
+ def _strip_replaygain_tags(self, output_file: str) -> None:
467
+ """
468
+ Strip ReplayGain tags from the output file after normalization.
469
+
470
+ This ensures that old ReplayGain tags from the input are removed,
471
+ since they are no longer accurate after normalization.
472
+
473
+ Args:
474
+ output_file (str): Path to the output file to strip tags from
475
+ """
476
+ _logger.debug(f"Stripping ReplayGain tags from {output_file}")
477
+
478
+ output_file_ext = os.path.splitext(output_file)[1]
479
+ if output_file_ext == ".mp3":
480
+ try:
481
+ mp3 = MP3(output_file, ID3=ID3)
482
+ if not mp3.tags:
483
+ return
484
+ # Remove REPLAYGAIN_* tags
485
+ tags_to_remove = [
486
+ key for key in mp3.tags if key.startswith("TXXX:REPLAYGAIN_")
487
+ ]
488
+ for tag in tags_to_remove:
489
+ del mp3.tags[tag]
490
+ if tags_to_remove:
491
+ mp3.save()
492
+ _logger.debug(
493
+ f"Stripped {len(tags_to_remove)} ReplayGain tag(s) from {output_file}"
494
+ )
495
+ except Exception as e:
496
+ _logger.warning(
497
+ f"Could not strip ReplayGain tags from {output_file}: {e}"
498
+ )
499
+ elif output_file_ext in [".mp4", ".m4a", ".m4v", ".mov"]:
500
+ try:
501
+ mp4 = MP4(output_file)
502
+ if not mp4.tags:
503
+ return
504
+ # Remove REPLAYGAIN_* tags
505
+ tags_to_remove = [
506
+ key
507
+ for key in mp4.tags
508
+ if "REPLAYGAIN_" in key.upper() or "REPLAYGAIN" in key.upper()
509
+ ]
510
+ for tag in tags_to_remove:
511
+ del mp4.tags[tag]
512
+ if tags_to_remove:
513
+ mp4.save()
514
+ _logger.debug(
515
+ f"Stripped {len(tags_to_remove)} ReplayGain tag(s) from {output_file}"
516
+ )
517
+ except Exception as e:
518
+ _logger.warning(
519
+ f"Could not strip ReplayGain tags from {output_file}: {e}"
520
+ )
521
+ elif output_file_ext == ".ogg":
522
+ try:
523
+ ogg = OggVorbis(output_file)
524
+ # Remove REPLAYGAIN_* tags (case-insensitive)
525
+ tags_to_remove = [
526
+ key for key in ogg.keys() if key.upper().startswith("REPLAYGAIN_")
527
+ ]
528
+ for tag in tags_to_remove:
529
+ del ogg[tag]
530
+ if tags_to_remove:
531
+ ogg.save()
532
+ _logger.debug(
533
+ f"Stripped {len(tags_to_remove)} ReplayGain tag(s) from {output_file}"
534
+ )
535
+ except Exception as e:
536
+ _logger.warning(
537
+ f"Could not strip ReplayGain tags from {output_file}: {e}"
538
+ )
539
+ elif output_file_ext == ".opus":
540
+ try:
541
+ opus = OggOpus(output_file)
542
+ # Remove R128_* tags (Opus uses R128 instead of REPLAYGAIN, case-insensitive)
543
+ tags_to_remove = [
544
+ key for key in opus.keys() if key.upper().startswith("R128_")
545
+ ]
546
+ for tag in tags_to_remove:
547
+ del opus[tag]
548
+ if tags_to_remove:
549
+ opus.save()
550
+ _logger.debug(
551
+ f"Stripped {len(tags_to_remove)} R128 tag(s) from {output_file}"
552
+ )
553
+ except Exception as e:
554
+ _logger.warning(
555
+ f"Could not strip ReplayGain tags from {output_file}: {e}"
556
+ )
557
+ else:
558
+ # For other formats, we don't need to strip tags
559
+ _logger.debug(
560
+ f"Skipping ReplayGain tag stripping for {output_file_ext} (not supported/needed)"
561
+ )
562
+
464
563
  def _can_write_output_video(self) -> bool:
465
564
  """
466
565
  Determine whether the output file can contain video at all.
@@ -94,6 +94,37 @@ class PresetManager:
94
94
 
95
95
  return sorted(presets)
96
96
 
97
+ def get_presets_with_source(self) -> list[tuple[str, str]]:
98
+ """Get list of available presets with their source location.
99
+
100
+ Returns:
101
+ list[tuple[str, str]]: List of (preset_name, source) tuples where
102
+ source is either "user" or "builtin"
103
+ """
104
+ user_presets: set[str] = set()
105
+ builtin_presets: set[str] = set()
106
+
107
+ # Get presets from user config directory
108
+ if self.presets_dir.exists():
109
+ for file in self.presets_dir.glob("*.json"):
110
+ user_presets.add(file.stem)
111
+
112
+ # Get default presets from package
113
+ if self.default_presets_dir.exists():
114
+ for file in self.default_presets_dir.glob("*.json"):
115
+ builtin_presets.add(file.stem)
116
+
117
+ result = []
118
+ all_presets = sorted(user_presets | builtin_presets)
119
+ for preset in all_presets:
120
+ # User presets take precedence
121
+ if preset in user_presets:
122
+ result.append((preset, "user"))
123
+ else:
124
+ result.append((preset, "builtin"))
125
+
126
+ return result
127
+
97
128
  def load_preset(self, preset_name: str) -> dict[str, Any]:
98
129
  """Load a preset file by name.
99
130