Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ Outputs are written under `../under_2gb/`. Existing generated output directories
- Split outputs are named with part suffixes, for example `meeting.wav.part0001.flac`, `meeting.wav.part0002.flac`.
- Tune silence detection with `--silence-noise` and `--silence-min-duration-seconds` when recordings need stricter or looser silence boundaries.

## Metadata tagging

- `--set-title`, `--set-artist`, `--set-album`, and `--set-comment` stamp the corresponding tags on every generated output, so archived files stay searchable in players and music libraries.
- Generated commands already copy source metadata with `-map_metadata 0`; the `--set-*` values are injected after it, so each provided key overrides that specific source tag while all other source metadata is preserved (standard ffmpeg semantics).
- When none of the `--set-*` options are passed, generated ffmpeg commands are byte-identical to the untagged behavior.
- Values are passed to ffmpeg as single argv items without a shell, so spaces, quotes, and other special characters are safe as given.

```bash
python3 media_shrinker.py .. --execute \
--set-album "Board Meetings 2026" \
--set-comment "archived by codec-carver"
```

## Safety notes

- Source files selected by the scan are protected from deletion or overwrite; keep `--output-dir` as a generated-only directory so excluded originals are never mistaken for stale generated outputs.
Expand Down
93 changes: 88 additions & 5 deletions media_shrinker.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,28 @@ def calculate_audio_bitrate(
return bitrate


def _metadata_args(tags: dict[str, str] | None) -> list[str]:
"""Return ffmpeg ``-metadata key=value`` arguments for the given tags.

Keys are emitted in sorted order so generated commands are deterministic.
The pairs are meant to be inserted after ``-map_metadata 0`` in a plan:
ffmpeg applies them on top of the metadata copied from the source, so each
provided key overrides the corresponding source tag while every other
source tag is preserved. Values are passed through verbatim as single
argv items (plans run without a shell), so special characters are safe.

Returns an empty list when tags is None or empty, keeping default plans
byte-identical to those built before metadata tagging existed.
"""

if not tags:
return []
args: list[str] = []
for key in sorted(tags):
args.extend(("-metadata", f"{key}={tags[key]}"))
return args


def build_audio_plan(
source_path: Path,
probe: MediaProbe,
Expand All @@ -321,8 +343,14 @@ def build_audio_plan(
prefer_flac: bool = False,
ffmpeg_threads: int | None = None,
segment: MediaSegment | None = None,
tags: dict[str, str] | None = None,
) -> ConversionPlan:
"""Build the preferred audio-only conversion plan for source_path."""
"""Build the preferred audio-only conversion plan for source_path.

When tags is provided, ``-metadata key=value`` pairs are injected after
``-map_metadata 0`` so they override those specific keys copied from the
source while leaving all other source metadata intact.
"""

if probe.has_video:
raise MediaShrinkerError(
Expand Down Expand Up @@ -356,6 +384,7 @@ def build_audio_plan(
"0",
"-map_chapters",
"0",
*_metadata_args(tags),
"-vn",
"-c:a",
"flac",
Expand All @@ -379,6 +408,7 @@ def build_audio_plan(
output_dir=output_dir,
ffmpeg_threads=ffmpeg_threads,
segment=segment,
tags=tags,
)


Expand All @@ -390,8 +420,14 @@ def build_opus_plan(
output_dir: Path,
ffmpeg_threads: int | None = None,
segment: MediaSegment | None = None,
tags: dict[str, str] | None = None,
) -> ConversionPlan:
"""Build a high-quality Opus plan that fits the target size."""
"""Build a high-quality Opus plan that fits the target size.

When tags is provided, ``-metadata key=value`` pairs are injected after
``-map_metadata 0`` so they override those specific keys copied from the
source while leaving all other source metadata intact.
"""

duration_seconds = (
segment.duration_seconds if segment is not None else probe.duration_seconds
Expand Down Expand Up @@ -420,6 +456,7 @@ def build_opus_plan(
"0",
"-map_chapters",
"0",
*_metadata_args(tags),
"-vn",
"-c:a",
"libopus",
Expand Down Expand Up @@ -727,8 +764,13 @@ def convert_file(
protected_sources: Iterable[Path] = (),
resolved_protected_sources: frozenset[Path] | None = None,
original_size: int | None = None,
tags: dict[str, str] | None = None,
) -> list[ConversionResult]:
"""Convert one file and return generated segment results without deleting the source."""
"""Convert one file and return generated segment results without deleting the source.

When tags is provided, each generated output is stamped with those
``-metadata`` key/value pairs on top of the metadata copied from the source.
"""

source = Path(source)
root = Path(root)
Expand Down Expand Up @@ -777,6 +819,7 @@ def convert_file(
overwrite=overwrite,
max_segment_duration_seconds=max_segment_duration_seconds,
protected_sources=resolved_sources,
tags=tags,
)
for segment in segments
]
Expand Down Expand Up @@ -874,6 +917,7 @@ def _execute_segment_conversion(
overwrite: bool,
max_segment_duration_seconds: float,
resolved_protected_sources: frozenset[Path],
tags: dict[str, str] | None = None,
) -> ConversionResult:
"""Execute the conversion plan(s) for a single segment."""
plan = build_audio_plan(
Expand All @@ -884,6 +928,7 @@ def _execute_segment_conversion(
prefer_flac=prefer_flac,
ffmpeg_threads=ffmpeg_threads,
segment=segment,
tags=tags,
)

final_output = _resolve_collision(plan.output_path, overwrite=overwrite)
Expand All @@ -910,6 +955,7 @@ def _execute_segment_conversion(
output_dir=output_dir,
ffmpeg_threads=ffmpeg_threads,
segment=segment,
tags=tags,
)
final_output = _resolve_collision(opus_plan.output_path, overwrite=overwrite)
first_result = _execute_plan(
Expand Down Expand Up @@ -994,6 +1040,7 @@ def _convert_segment(
overwrite: bool,
max_segment_duration_seconds: float,
protected_sources: frozenset[Path] = frozenset(),
tags: dict[str, str] | None = None,
) -> ConversionResult:
"""Convert one media segment fitting the target size limit."""
# protected_sources is passed from convert_file where it is already fully resolved.
Expand Down Expand Up @@ -1043,6 +1090,7 @@ def _convert_segment(
overwrite=overwrite,
max_segment_duration_seconds=max_segment_duration_seconds,
resolved_protected_sources=resolved_protected_sources,
tags=tags,
)


Expand Down Expand Up @@ -1143,7 +1191,13 @@ def _normalize_argv(argv: list[str] | None) -> list[str] | None:


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse CLI arguments."""
"""Parse CLI arguments.

The returned namespace also carries a ``tags`` dict collecting the
``--set-title``/``--set-artist``/``--set-album``/``--set-comment`` values
that were actually provided; it is empty when none were passed, which
keeps generated ffmpeg commands byte-identical to the untagged behavior.
"""

parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
Expand Down Expand Up @@ -1247,7 +1301,35 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
action="store_true",
help="Allow overwriting generated output paths",
)
return parser.parse_args(_normalize_argv(argv))
parser.add_argument(
"--set-title",
default=None,
help="Set the 'title' metadata tag on every generated output",
)
parser.add_argument(
"--set-artist",
default=None,
help="Set the 'artist' metadata tag on every generated output",
)
parser.add_argument(
"--set-album",
default=None,
help="Set the 'album' metadata tag on every generated output",
)
parser.add_argument(
"--set-comment",
default=None,
help="Set the 'comment' metadata tag on every generated output",
)
args = parser.parse_args(_normalize_argv(argv))
provided_tags = {
"title": args.set_title,
"artist": args.set_artist,
"album": args.set_album,
"comment": args.set_comment,
}
args.tags = {key: value for key, value in provided_tags.items() if value is not None}
return args


def _execute_conversions(
Expand Down Expand Up @@ -1286,6 +1368,7 @@ def process_candidate(candidate_tuple: tuple[Path, int]) -> list[ConversionResul
protected_sources=protected_sources,
resolved_protected_sources=resolved_candidates,
original_size=size,
tags=args.tags or None,
)
except Exception as exc: # noqa: BLE001 - batch processing records per-file failures.
return [
Expand Down
Loading
Loading