|
|
@@ -0,0 +1,171 @@
|
|
|
+# zz_remux — Design Document
|
|
|
+
|
|
|
+## Purpose
|
|
|
+
|
|
|
+`zz_remux` is a batch media processing tool that wraps three external CLI programs — `mkvmerge`, `mkvpropedit`, and `ffprobe` — to automate two common post-download tasks:
|
|
|
+
|
|
|
+1. **Muxing**: Merge loose SRT subtitle files into their matching video file, producing a single MKV.
|
|
|
+2. **Language tagging**: Ensure every audio and subtitle stream in a video has a valid language tag; add `SDH` title metadata to hearing-impaired subtitle tracks.
|
|
|
+
|
|
|
+It is designed to run repeatedly over a media library. State is persisted via xattr tags so already-processed files are skipped without re-running expensive external tools.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Module overview
|
|
|
+
|
|
|
+```
|
|
|
+zz_remux/
|
|
|
+├── __init__.py — Public re-exports: Consts, Tag, Scan, Mux, Lang, Proc
|
|
|
+├── __main__.py — Package entry (delegates to cli.main)
|
|
|
+├── cli.py — Argument parsing, logging setup, directory walk, orchestration
|
|
|
+├── consts.py — Shared constants: extensions, tag names/versions, defaults
|
|
|
+├── scan.py — File discovery and metadata parsing
|
|
|
+├── mux.py — mkvmerge orchestration (subtitle merging)
|
|
|
+├── lang.py — mkvpropedit orchestration (language/SDH fixup)
|
|
|
+├── probe.py — ffprobe wrapper (stream metadata)
|
|
|
+├── proc.py — Generic subprocess runner
|
|
|
+└── tag.py — Filesystem xattr read/write helpers
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Data flow
|
|
|
+
|
|
|
+```
|
|
|
+cli.py: root.walk()
|
|
|
+ │
|
|
|
+ ▼
|
|
|
+scan.Scan.scan(files, parent)
|
|
|
+ │ Parses each filename against the media regex.
|
|
|
+ │ Returns a list of "info" dicts (see File info dict below).
|
|
|
+ │ Reads mux_vers and lang_vers xattrs from each file.
|
|
|
+ │
|
|
|
+ ├─▶ mux.Mux.mux(media)
|
|
|
+ │ Groups info dicts by group name.
|
|
|
+ │ For each group with a video + subtitles:
|
|
|
+ │ - Builds mkvmerge args (language, SDH flag per subtitle)
|
|
|
+ │ - Runs mkvmerge to a .mkv-remux temp file
|
|
|
+ │ - Verifies output size
|
|
|
+ │ - Sets zib.mux xattr on temp file
|
|
|
+ │ - Deletes source files
|
|
|
+ │ - Renames temp -> final .mkv
|
|
|
+ │ - Updates media list in-place (removes sources, appends new entry)
|
|
|
+ │
|
|
|
+ └─▶ lang.Lang.lang(media)
|
|
|
+ For each video file not yet at current lang_vers:
|
|
|
+ - Runs ffprobe via probe.Probe to get stream list
|
|
|
+ - Finds audio/subtitle streams with undefined/missing language
|
|
|
+ - Finds hearing-impaired subtitle streams missing an SDH title
|
|
|
+ - Runs mkvpropedit to set language and/or title on those streams
|
|
|
+ - Sets zib.lang xattr on file
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File info dict
|
|
|
+
|
|
|
+`Scan.scan()` returns a list of dicts. `Mux.mux()` mutates this list in-place (removing merged sources, appending the new output file). `Lang.lang()` reads from it and updates `lang_vers`.
|
|
|
+
|
|
|
+```python
|
|
|
+{
|
|
|
+ 'path': pathlib.Path, # absolute path to the file
|
|
|
+ 'name': str, # filename only (no directory)
|
|
|
+ 'ext': str, # lowercased extension: 'mkv', 'mp4', 'm4v', 'srt'
|
|
|
+ 'group': str, # base name shared by the file group
|
|
|
+ 'lang': str | None, # ISO 639-2/T alpha-3 code, or None
|
|
|
+ 'sdh': bool, # True if filename contains the 'sdh' segment
|
|
|
+ 'mux_vers': int | None, # value of zib.mux xattr, or None if not set
|
|
|
+ 'lang_vers': int | None, # value of zib.lang xattr, or None if not set
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Filename parsing
|
|
|
+
|
|
|
+`Scan` builds a single regex from the constants in `Consts`:
|
|
|
+
|
|
|
+```
|
|
|
+^(.*?)(?:\.(\w\w))?(?:\.(sdh))?(?:\.synced)?\.(<ext>)$
|
|
|
+```
|
|
|
+
|
|
|
+Capture groups:
|
|
|
+1. `group` — everything before the first optional suffix
|
|
|
+2. `lang` — optional two-letter language code
|
|
|
+3. `sdh` — optional literal `sdh`
|
|
|
+4. `ext` — file extension
|
|
|
+
|
|
|
+The matched language code is normalized to ISO 639-2/T (alpha-3) via `langcodes.Language.get(...).to_alpha3()`.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## xattr tag system
|
|
|
+
|
|
|
+Two xattr keys are defined in `Consts`:
|
|
|
+
|
|
|
+| Key | Version constant | Meaning |
|
|
|
+|---|---|---|
|
|
|
+| `zib.mux` | `Consts.mux_vers = 1` | File was successfully muxed by this version |
|
|
|
+| `zib.lang` | `Consts.lang_vers = 2` | File had language/SDH fixup applied by this version |
|
|
|
+
|
|
|
+Values are stored as raw bytes (little-endian integer, using Python's `int.to_bytes()`).
|
|
|
+
|
|
|
+To force reprocessing of all files, increment the relevant version constant. Files with a stored version less than the current constant will be reprocessed.
|
|
|
+
|
|
|
+`Tag` (`tag.py`) is the low-level xattr accessor. It wraps the `xattr` library and provides typed get/set helpers (`get_int`, `set_int`, `get_str`, `set_str`).
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## External tool interfaces
|
|
|
+
|
|
|
+### mkvmerge (Mux)
|
|
|
+
|
|
|
+```
|
|
|
+mkvmerge --default-language eng -o <output.mkv-remux> <video.mkv>
|
|
|
+ [--language 0:<lang> [--hearing-impaired-flag 0] <subtitle.srt>] ...
|
|
|
+```
|
|
|
+
|
|
|
+Exit code 0 = success. The tool is called once per file group.
|
|
|
+
|
|
|
+### mkvpropedit (Lang)
|
|
|
+
|
|
|
+```
|
|
|
+mkvpropedit <video.mkv>
|
|
|
+ [--edit track:<n+1> --set language=eng] ...
|
|
|
+ [--edit track:<n+1> --set name=SDH] ...
|
|
|
+```
|
|
|
+
|
|
|
+Note: mkvpropedit track numbers are 1-based; ffprobe stream indices are 0-based, so the code adds 1 when building the args.
|
|
|
+
|
|
|
+### ffprobe (Probe)
|
|
|
+
|
|
|
+```
|
|
|
+ffprobe -hide_banner -show_streams -print_format json <video.mkv>
|
|
|
+```
|
|
|
+
|
|
|
+Returns JSON; `Probe.probe()` parses and returns the `streams` list. Each stream is a dict with at minimum `index`, `codec_type`, `disposition`, and `tags` keys.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Subprocess runner
|
|
|
+
|
|
|
+`Proc.run(cmd)` is a thin wrapper around `subprocess.run`. It captures stdout and stderr, decodes them as UTF-8, and returns:
|
|
|
+
|
|
|
+```python
|
|
|
+{ 'out': str | None, 'err': str | None, 'code': int }
|
|
|
+```
|
|
|
+
|
|
|
+A sentinel exit code of `-111` is used if `returncode` cannot be read. Non-zero exit codes are logged as errors.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Known limitations and tech debt
|
|
|
+
|
|
|
+- **No dry-run mode.** There is no way to preview what would be muxed/changed without actually running the external tools and modifying files.
|
|
|
+- **Mux is destructive.** Source video and subtitle files are deleted after a successful mux. A failed mux (wrong exit code, output too small) leaves a `.mkv-remux` temp file on disk that blocks future runs for that group until manually removed.
|
|
|
+- **Single video per group.** Groups with zero or more than one video file are skipped with an error log. There is no mechanism to resolve ambiguity.
|
|
|
+- **xattrs are filesystem-dependent.** Tags are silently lost when files cross filesystem boundaries (e.g., moving from a staging share to final storage). This causes files to be reprocessed, which is safe but redundant.
|
|
|
+- **No tests.** There is no test suite. External tools make unit testing awkward; integration tests against a fixture directory would be the natural approach.
|
|
|
+- **No type hints.** All public APIs take and return untyped dicts. A typed `FileInfo` dataclass or `TypedDict` would clarify contracts.
|
|
|
+- **Python version floor is understated.** `pyproject.toml` declares `requires-python = ">= 3.8"` but the codebase uses `int.from_bytes(x)` / `int.to_bytes()` without the `byteorder` argument (requires 3.11+) and f-strings with nested quotes (requires 3.12+). The floor should be `>= 3.12`.
|
|
|
+- **Build config is broken.** `pyproject.toml` sets `[tool.hatch.build] source = "src"` but no `src/` directory exists. The package lives at the project root and would not build correctly with hatchling without correction.
|