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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@ python3 media_shrinker.py .. \

Outputs are written under `../under_2gb/`. Existing generated output directories and `split_over*` directories should be excluded from scans to avoid reconverting generated media. Files under 2GB are included by default; use `--over-limit-only` only when intentionally processing oversized sources exclusively.

## Config file for repeat workflows

Instead of re-typing long flag sets, store them once in a `.codec-carver.json` file in the scan root (checked first) or the current working directory:

```json
{
"flac_all": true,
"exclude_dir_prefix": ["split_over"],
"max_duration_seconds": 14400,
"workers": 2,
"output_dir": "under_2gb"
}
```

Then repeat runs collapse to `python3 media_shrinker.py .. --execute --download-icloud`.

- Keys map 1:1 to CLI options with dashes replaced by underscores (`--target-bytes` becomes `target_bytes`).
- Explicit CLI flags always override config values; without a config file, behavior is identical to a plain invocation.
- `root` and `--execute` are intentionally not configurable: the config file is discovered via the scan root, and a config file must never silently turn a dry run into a real conversion.
- Unknown keys, wrong value types, and malformed JSON abort with a clear error listing the valid keys.
- JSON is used instead of TOML because the stdlib TOML parser requires Python 3.11+, while this project also supports Python 3.10.

## Duration splitting

- `--max-duration-seconds 14400` keeps every generated file below four hours.
Expand Down
190 changes: 190 additions & 0 deletions config_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Optional per-project config file support for media_shrinker.

Users who run the same shrink workflow repeatedly can store their flag set in
a ``.codec-carver.json`` file at the scan root (or the current working
directory) instead of re-typing long command lines. Values from the config
file are applied only where the user did not pass an explicit CLI flag, so
the command line always wins and a missing config file leaves behavior
byte-identical to a plain invocation.

Format choice: JSON instead of TOML. The stdlib TOML parser (``tomllib``)
only exists on Python 3.11+, while this project also supports Python 3.10.
``json`` is available on every supported interpreter, needs no third-party
dependency, and covers the flat key/value shape this file requires.

Example ``.codec-carver.json``::

{
"target_bytes": 500000000,
"flac_all": true,
"workers": 2,
"exclude_dir_prefix": ["split_", "tmp_"]
}

Keys map 1:1 to ``media_shrinker`` CLI options with dashes replaced by
underscores (``--target-bytes`` becomes ``target_bytes``). The ``root``
positional and the ``--execute`` flag are deliberately not configurable:
``root`` is where the config file itself is discovered, and ``--execute``
must stay an explicit per-run decision so a config file can never silently
turn a dry run into a real conversion.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any


CONFIG_FILENAME = ".codec-carver.json"


class ConfigFileError(ValueError):
"""Raised when a config file exists but cannot be used safely."""


def _bool_value(key: str, value: Any) -> bool:
"""Return value if it is a JSON boolean, else raise ConfigFileError."""

if isinstance(value, bool):
return value
raise ConfigFileError(_type_error_message(key, "a boolean (true/false)", value))


def _int_value(key: str, value: Any) -> int:
"""Return value if it is a JSON integer, else raise ConfigFileError."""

if isinstance(value, int) and not isinstance(value, bool):
return value
raise ConfigFileError(_type_error_message(key, "an integer", value))


def _float_value(key: str, value: Any) -> float:
"""Return value as float if it is a JSON number, else raise ConfigFileError."""

if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
raise ConfigFileError(_type_error_message(key, "a number", value))


def _str_value(key: str, value: Any) -> str:
"""Return value if it is a JSON string, else raise ConfigFileError."""

if isinstance(value, str):
return value
raise ConfigFileError(_type_error_message(key, "a string", value))


def _path_value(key: str, value: Any) -> Path:
"""Return value converted to a Path if it is a JSON string."""

return Path(_str_value(key, value))


def _str_list_value(key: str, value: Any) -> list[str]:
"""Return value if it is a JSON array of strings, else raise ConfigFileError."""

if isinstance(value, list) and all(isinstance(item, str) for item in value):
return list(value)
raise ConfigFileError(_type_error_message(key, "an array of strings", value))


def _type_error_message(key: str, expected: str, value: Any) -> str:
"""Build a consistent type-mismatch error message for one config key."""

return (
f"Config key '{key}' expects {expected}, "
f"got {type(value).__name__}: {value!r}"
)


#: Allowlisted config keys mapped to their validating converter. Each key is a
#: real ``media_shrinker.parse_args`` argparse dest so validated values can be
#: assigned onto the parsed namespace unchanged.
CONFIG_SCHEMA = {
"size_limit_bytes": _int_value,
"target_bytes": _int_value,
"max_duration_seconds": _float_value,
"output_dir": _path_value,
"report": _path_value,
"ffmpeg": _str_value,
"ffprobe": _str_value,
"brctl": _str_value,
"download_icloud": _bool_value,
"include_under_limit": _bool_value,
"exclude_dir_prefix": _str_list_value,
"flac_all": _bool_value,
"workers": _int_value,
"ffmpeg_threads": _int_value,
"silence_noise": _str_value,
"silence_min_duration_seconds": _float_value,
"overwrite": _bool_value,
}


def find_config_path(root: Path) -> Path | None:
"""Return the config file path for a run, or None when no file exists.

The scan root is checked first so per-library configs travel with the
media they describe; the current working directory is the fallback for
users who keep one config next to where they launch the tool.
"""

for base in (Path(root), Path.cwd()):
candidate = base / CONFIG_FILENAME
if candidate.is_file():
return candidate
return None


def validate_config(raw: dict[str, Any], *, source: Path) -> dict[str, Any]:
"""Validate raw config data against CONFIG_SCHEMA and convert values.

Raises:
ConfigFileError: If any key is unknown or any value has the wrong type.
"""

unknown = sorted(set(raw) - set(CONFIG_SCHEMA))
if unknown:
raise ConfigFileError(
f"Unknown config key(s) in {source}: {', '.join(unknown)}. "
f"Valid keys: {', '.join(sorted(CONFIG_SCHEMA))}"
)
return {key: CONFIG_SCHEMA[key](key, value) for key, value in raw.items()}


def load_config(root: Path) -> dict[str, Any]:
"""Load and validate the ``.codec-carver.json`` config for a scan root.

Looks for the file in ``root`` first, then in the current working
directory. Returns an empty dict when no config file exists, so callers
can treat "no config" and "empty config" identically.

Raises:
ConfigFileError: If the file exists but is unreadable, is not valid
JSON, is not a JSON object, contains unknown keys, or contains
values of the wrong type.
"""

path = find_config_path(root)
if path is None:
return {}

try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise ConfigFileError(f"Could not read config file {path}: {exc}") from exc

try:
raw = json.loads(text)
except json.JSONDecodeError as exc:
raise ConfigFileError(f"Malformed JSON in config file {path}: {exc}") from exc

if not isinstance(raw, dict):
raise ConfigFileError(
f"Config file {path} must contain a JSON object at the top level, "
f"got {type(raw).__name__}"
)

return validate_config(raw, source=path)
69 changes: 66 additions & 3 deletions media_shrinker.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
from pathlib import Path
from typing import Any, Iterable

import config_file


SUPPORTED_EXTENSIONS = {
".3gp",
Expand Down Expand Up @@ -1142,8 +1144,8 @@ def _normalize_argv(argv: list[str] | None) -> list[str] | None:
return normalized


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse CLI arguments."""
def _build_parser() -> argparse.ArgumentParser:
"""Build the CLI argument parser."""

parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
Expand Down Expand Up @@ -1247,7 +1249,68 @@ 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))
return parser


def _explicitly_set_dests(
parser: argparse.ArgumentParser,
argv: list[str] | None,
dests: Iterable[str],
) -> set[str]:
"""Return the subset of dests the user explicitly set on the command line.

Uses the documented argparse behavior that pre-set namespace attributes
are kept unless an option is actually provided: each dest is seeded with
a unique sentinel, argv is re-parsed onto that namespace, and any dest
that no longer holds the sentinel was explicitly given by the user.
"""

sentinel = object()
dests = tuple(dests)
namespace = argparse.Namespace(**{dest: sentinel for dest in dests})
parser.parse_args(argv, namespace=namespace)
return {dest for dest in dests if getattr(namespace, dest) is not sentinel}


def _apply_config_file(
parser: argparse.ArgumentParser,
args: argparse.Namespace,
argv: list[str] | None,
) -> None:
"""Overlay `.codec-carver.json` values onto args for unset CLI options.

Explicit CLI flags always win over config-file values; with no config
file present, args is returned untouched. Config problems (unknown keys,
type mismatches, malformed JSON) abort with a normal argparse error
message instead of a traceback.
"""

try:
config = config_file.load_config(Path(args.root))
except config_file.ConfigFileError as exc:
parser.error(str(exc))
if not config:
return
explicit = _explicitly_set_dests(parser, argv, config)
for dest, value in config.items():
if dest not in explicit:
setattr(args, dest, value)


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse CLI arguments, overlaying `.codec-carver.json` config defaults.

A `.codec-carver.json` file in the scan root (or the current working
directory) supplies defaults for repeat workflows; see the config_file
module for the format and the allowed keys. Values from the config file
apply only where the user did not pass the corresponding CLI flag.
"""

parser = _build_parser()
normalized = _normalize_argv(argv)
args = parser.parse_args(normalized)
_apply_config_file(parser, args, normalized)
return args


def _execute_conversions(
Expand Down
Loading
Loading