Răsfoiți Sursa

Initial commit

Pre-existing codebase with README.md and DESIGN.md added to document
current functionality, architecture, and known issues.
ZiB 4 luni în urmă
comite
7beb3e2d7c

+ 171 - 0
zz_remux/DESIGN.md

@@ -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.

+ 103 - 0
zz_remux/README.md

@@ -0,0 +1,103 @@
+# zz_remux
+
+Automates MKV remuxing and metadata cleanup for a library of media files.
+
+Given a directory of video files and matching SRT subtitle files, `zz_remux` will:
+
+1. **Mux** — Merge SRT subtitle files into their matching video file using `mkvmerge`, producing a single `.mkv` output and removing the originals.
+2. **Lang** — Probe all video files with `ffprobe` and use `mkvpropedit` to fix undefined language tags on audio/subtitle streams, and add a `SDH` title to hearing-impaired subtitle tracks that are missing one.
+
+Processing state is tracked via filesystem extended attributes (xattrs) so files are not re-processed on subsequent runs.
+
+## Requirements
+
+### System tools (must be on `PATH`)
+
+| Tool | Package |
+|---|---|
+| `mkvmerge` | [MKVToolNix](https://mkvtoolnix.download/) |
+| `mkvpropedit` | MKVToolNix |
+| `ffprobe` | [FFmpeg](https://ffmpeg.org/) |
+
+### Python
+
+- Python 3.12+
+- [`xattrs`](https://pypi.org/project/xattrs/) — filesystem extended attribute access
+- [`langcodes`](https://pypi.org/project/langcodes/) — language code normalization
+
+## Installation
+
+```sh
+pip install .
+```
+
+Or with `uv`:
+
+```sh
+uv pip install .
+```
+
+## Usage
+
+```
+zz_remux [-h] [-m MATCH] [-n] [-v] [-d] base_directory
+```
+
+| Flag | Description |
+|---|---|
+| `base_directory` | Root directory to process recursively |
+| `-m MATCH`, `--match MATCH` | Only process file groups whose base name matches this regex (case-insensitive) |
+| `-n`, `--no-recursion` | Process only the top-level directory, do not recurse |
+| `-v`, `--verbose` | Enable INFO-level logging |
+| `-d`, `--debug` | Enable DEBUG-level logging |
+
+The `DEBUG` environment variable can also be set to `INFO` or any other value to control log level before the CLI parses arguments.
+
+### Example
+
+```sh
+# Process an entire media library
+zz_remux /media/movies
+
+# Dry-run a specific title
+zz_remux /media/movies --match "The.Matrix" --verbose
+```
+
+## Filename convention
+
+Files are grouped by their base name (everything before the first language/SDH/extension suffix):
+
+```
+{group}[.{lang}][.sdh][.synced].{ext}
+```
+
+| Segment | Description |
+|---|---|
+| `group` | Shared base name (e.g. `The.Matrix.2025`) |
+| `lang` | Optional two-letter language code (e.g. `en`, `fr`) |
+| `sdh` | Optional literal `sdh` — marks the track as hearing-impaired |
+| `synced` | Optional literal `synced` — ignored, stripped from the pattern |
+| `ext` | `mkv`, `mp4`, `m4v` (video) or `srt` (subtitle) |
+
+**Example group:**
+
+```
+The.Matrix.2025.mkv
+The.Matrix.2025.en.srt
+The.Matrix.2025.fr.sdh.srt
+```
+
+These three files form one group. `mkvmerge` will produce `The.Matrix.2025.mkv` containing the video and both subtitle tracks.
+
+## Processing tags
+
+`zz_remux` stores version numbers in filesystem xattrs to avoid reprocessing:
+
+| Attribute | Operation |
+|---|---|
+| `zib.mux` | Mux version — set after a successful mkvmerge run |
+| `zib.lang` | Lang version — set after a successful mkvpropedit run |
+
+If the stored version is current, the file is skipped. Incrementing `Consts.mux_vers` or `Consts.lang_vers` in `consts.py` will force reprocessing on the next run.
+
+> **Note:** xattrs are not preserved by all filesystems or copy operations. If files are moved across filesystems (e.g. from a staging NAS to a final storage volume), the tags will be lost and files will be reprocessed.

+ 37 - 0
zz_remux/pyproject.toml

@@ -0,0 +1,37 @@
+[build-system]
+requires = ["hatchling >= 1.26"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+source = "src"
+
+[project]
+name = "zz_remux"
+version = "0.0.2"
+
+description = "Runs mkvmerge, mkvpropedit, and ffprobe in useful ways"
+keywords = ["mkvmerge", "mkvpropedit", "ffprobe", "subtitles", "mkv", "srt"]
+
+requires-python = ">= 3.8"
+dependencies = [
+  "xattrs",
+  "langcodes",
+]
+
+authors = [
+  {name = "ZiB", email = "zib@zi3.xyz"},
+]
+license = "GPL-3.0-only"
+
+classifiers = [
+  "Development Status :: 2 - Pre-Alpha",
+  "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+  "Topic :: Multimedia :: Video :: Conversion",
+]
+
+[project.scripts]
+zz_remux = "zz_remux.cli:main"
+
+[project.urls]
+source = "https://git.zi3.xyz/zib/Loki/src/main/zz_remux"
+funding = "https://4114.us/give"

+ 2 - 0
zz_remux/requirements.txt

@@ -0,0 +1,2 @@
+xattrs
+langcodes

+ 6 - 0
zz_remux/zz_remux/__init__.py

@@ -0,0 +1,6 @@
+from .consts import Consts
+from .tag import Tag
+from .scan import Scan
+from .mux import Mux
+from .lang import Lang
+from .proc import Proc

+ 3 - 0
zz_remux/zz_remux/__main__.py

@@ -0,0 +1,3 @@
+from .cli import main
+
+main()

+ 89 - 0
zz_remux/zz_remux/cli.py

@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+import re, sys, os, argparse, pathlib, logging
+import zz_remux as remux
+
+def main():
+  # Static config
+  debug     = False
+  log_level = logging.WARN
+  prog_name = 'zz_remux'
+
+  # ENV config
+  if 'DEBUG' in os.environ:
+    if os.environ['DEBUG'] == 'INFO':
+      log_level = logging.INFO
+    else:
+      log_level = logging.DEBUG
+
+  # CLI config
+  parser = argparse.ArgumentParser(prog=prog_name,
+    description='Calls mkvmerge, mkvpropedit and ffprobe in useful ways')
+  parser.add_argument('root', metavar='base_directory', type=pathlib.Path,
+    help='Recursively process all media files in this directory')
+  parser.add_argument('--match',   '-m',
+    help='Only process files that match this (case-insensitive) regex')
+  parser.add_argument('--no-recursion', '-n', action='store_true', help='Do not recurse')
+  parser.add_argument('--verbose', '-v', action='store_true', help='Log more')
+  parser.add_argument('--debug',   '-d', action='store_true', help='Log lots more')
+
+  args = None
+  try:
+    args = parser.parse_args()
+  except:
+    parser.print_help()
+    sys.exit(-1)
+  if args.debug:
+    log_level = logging.DEBUG
+  elif args.verbose:
+    log_level = logging.INFO
+
+  if not args.root.is_dir():
+    print(f"Invalid base_directory: {args.root}", file=sys.stderr)
+    parser.print_help()
+    sys.exit(-1)
+  root = args.root
+
+  match = None
+  if args.match:
+    try:
+      match = re.compile(args.match, re.IGNORECASE)
+      args.match = True
+    except:
+      print(f"Unable to compile match regex: {args.match}", file=sys.stderr)
+      parser.print_help()
+      sys.exit(-1)
+
+  recurse = True
+  if args.no_recursion:
+    recurse = False
+
+  del args
+  del parser
+
+  # Logging
+  logging.basicConfig(stream=sys.stderr)
+  logger = logging.getLogger(prog_name)
+  logger.setLevel(log_level)
+
+  # Module init
+  mux  = remux.Mux(logLevel=log_level)
+  scan = remux.Scan(logLevel=log_level)
+  lang = remux.Lang(logLevel=log_level)
+
+  # Walk
+  logger.debug(f"Root: {root}")
+  for (parent, dirs, files) in root.walk():
+    logger.debug(f"Walking: {parent}")
+
+    media = scan.scan(files, parent, match=match)
+    mux.mux(media)
+    lang.lang(media)
+
+    # Non-recursion
+    if not recurse:
+      logger.info("Not recursing")
+      return
+
+# Exec
+if __name__ == "__main__":
+  main()

+ 37 - 0
zz_remux/zz_remux/consts.py

@@ -0,0 +1,37 @@
+class Consts:
+  # Default file metadata
+  _default = {
+    'path':      None,
+    'name':      '',
+    'ext':       '',
+    'group':     '',
+    'lang':      None,
+    'sdh':       False,
+    'mux_vers':  None,
+    'lang_vers': None,
+  }
+
+  # Parsing constants
+  video_ext     = [ r'mkv', r'mp4', r'm4v' ]
+  subs_ext      = [ r'srt' ]
+  subs_sdh      = [ r'sdh' ]
+  subs_lang     = [ r'\w\w' ]
+  media_ext     = video_ext + subs_ext
+
+  # Tag data
+  mux_tag   = 'zib.mux'
+  mux_vers  = 1
+  lang_tag  = 'zib.lang'
+  lang_vers = 2
+
+  # Validation size factor
+  min_size = 0.9
+
+  # Stream constants
+  lang_undef   = 'und'
+  lang_default = 'eng'
+  lang_codecs  = [ 'subtitle', 'audio' ]
+  lang_missing = [ None, lang_undef ]
+
+  def default():
+    return Consts._default.copy()

+ 78 - 0
zz_remux/zz_remux/lang.py

@@ -0,0 +1,78 @@
+import logging
+from .consts import Consts
+from .tag import Tag
+from .probe import Probe
+from .proc import Proc
+
+class Lang:
+  sdh_title = 'SDH'
+
+  def __init__(self, propeditExec='mkvpropedit', logLevel=logging.WARN):
+    # Logging
+    self.logger = logging.getLogger(__name__)
+    self.logger.setLevel(logLevel)
+    
+    # Module init
+    self.propedit_exec = propeditExec
+    self.tags = Tag(logLevel=logLevel)
+    self.probes = Probe(logLevel=logLevel)
+    self.proc = Proc(logLevel=logLevel)
+
+  def lang(self, media):
+    self.logger.info(f"Processing {len(media)} files")
+
+    for file in media:
+      self.logger.info(f"Found media: {file['name']}")
+
+      # Skip files that do not need lang processing
+      if not file['ext'] in Consts.video_ext:
+        self.logger.debug(f"Skipping non-video item: {file['path']}")
+        continue
+      if 'lang_vers' in file and file['lang_vers'] and file['lang_vers'] >= Consts.lang_vers:
+        self.logger.debug(f"Skipping item with current lang_vers: {file['path']}")
+        continue
+
+      # Probe streams
+      streams = self.probes.probe(file['path'])
+      for stream in streams:
+        if not 'index' in stream:
+          self.logger.error(f"No index for stream ({file['name']}): {stream}")
+          continue
+
+      # Find missing and undefined languages for subtitle and audio streams
+      # Find hearing_impaired subtitles without SDH title
+      add_lang = []
+      add_sdh  = []
+      for stream in streams:
+        if stream.get('codec_type') not in Consts.lang_codecs:
+          self.logger.debug(f"Skipping missing or ignored codec_type: {file['name']}:{stream['index']}")
+          continue
+        if stream.get('tags', {}).get('language') in Consts.lang_missing:
+          self.logger.info(f"Missing/undefined language in {file['name']}:{stream['index']}:{stream['codec_type']}")
+          add_lang.append(int(stream['index']))
+        title = stream.get('tags', {}).get('title')
+        if (
+          stream.get('disposition', {}).get('hearing_impaired') == 1
+          and (not title or self.sdh_title not in title)
+        ):
+          self.logger.info(f"Missing SDH title for hearing-impaired track {file['name']}:{stream['index']}:{stream['codec_type']}")
+          add_sdh.append(int(stream['index']))
+        del title
+
+      # mkvpropedit
+      if add_lang or add_sdh:
+        # Remember mkvpropedit starts counting at 1
+        args = [ self.propedit_exec, file['path'] ]
+        for index in add_lang:
+          self.logger.info(f"Setting lang ({Consts.lang_default}): {file['name']}:{index}")
+          args.extend([ '--edit', f"track:{index + 1}", '--set', f"language={Consts.lang_default}" ])
+        for index in add_sdh:
+          self.logger.info(f"Setting hearing-impaired title ({self.sdh_title}): {file['name']}:{index}")
+          args.extend([ '--edit', f"track:{index + 1}", '--set', f"name={self.sdh_title}" ])
+        if self.proc.run(args)['code'] != 0:
+          continue
+
+      # Set tag
+      if not self.tags.set_int(file['path'], Consts.lang_tag, Consts.lang_vers):
+        continue
+      file['lang_vers'] = Consts.lang_vers

+ 131 - 0
zz_remux/zz_remux/mux.py

@@ -0,0 +1,131 @@
+import sys, os, pathlib, logging
+from .consts import Consts
+from .tag import Tag
+from .proc import Proc
+
+class Mux:
+  def __init__(self, logLevel=logging.WARN):
+    # Logging
+    self.logger = logging.getLogger(__name__)
+    self.logger.setLevel(logLevel)
+
+    # Module init
+    self.tags = Tag(logLevel=logLevel)
+    self.proc = Proc(logLevel=logLevel)
+
+  def mux(self, media):
+    self.logger.info(f"Processing {len(media)} files")
+
+    # Find groups
+    groups  = {}
+    for file in media:
+      if not file['group']:
+        self.logger.debug(f"Skipping non-grouped file: {file['name']}")
+        continue
+      gnorm = file['group'].lower()
+      if gnorm not in groups:
+        groups[gnorm] = []
+      if file not in groups[gnorm]:
+        groups[gnorm].append(file)
+
+    # Merge groups
+    for group in groups.values():
+      self.logger.info(f"Muxing group: {group[0]['group']}")
+
+      # Skip groups that do not need muxing
+      if len(group) == 1:
+        if 'mux_vers' in group[0] and group[0]['mux_vers'] and group[0]['mux_vers'] >= Consts.mux_vers:
+          self.logger.debug(f"Skipping single-item group with current mux_vers: {group[0]['path']}")
+          continue
+
+      # Find all sources for the group
+      size    = 0
+      subs    = []
+      videos  = []
+      for file in group:
+        if file['ext'] in Consts.video_ext:
+          self.logger.info(f"Found video: {file['name']}")
+          videos.append(file)
+        elif file['ext'] in Consts.subs_ext:
+          self.logger.info(f"Found subtitle: {file['name']}")
+          subs.append(file)
+        else:
+          self.logger.warning(f"Skipping unexpected source while muxing: {file['name']}")
+        # Track total size for post-mux verification
+        size += file['path'].stat().st_size
+
+      # We should find exactly one video in the group
+      if len(videos) != 1:
+        self.logger.error(f"Did not find exactly 1 video in group: {group[0]['group']}")
+        continue
+
+      # Build args
+      final   = videos[0]['path'].parent.joinpath(f'{videos[0]['group']}.mkv')
+      temp    = final.with_suffix('.mkv-remux')
+      args    = [ 'mkvmerge', '--default-language', Consts.lang_default, '-o', temp ]
+      args.append(videos[0]['path'])
+
+      # Subtitles get language and SDH metadata
+      # Index is always 0; these files contain only one stream
+      for sub in subs:
+        if sub['lang'] in Consts.lang_missing:
+          sub['lang'] = Consts.lang_default
+        args.extend([ '--language', f"0:{sub['lang']}" ])
+        if sub['sdh']:
+          args.extend([ '--hearing-impaired-flag', '0' ])
+        args.append(sub['path'])
+
+      # mkvmerge
+      self.logger.info(f"Copying: {final.name}")
+      if temp.exists():
+        self.logger.error(f"Skipping because mux output exists: {temp}")
+        continue
+      merge = self.proc.run(args)
+
+      # Verify
+      err = None
+      if merge['code'] != 0:
+        err = f"Non-zero return ({merge['code']})"
+      elif not temp.exists():
+        err = f"Output file missing: {temp}"
+      elif temp.stat().st_size < (Consts.min_size * size):
+        err = f"Output file too small ({temp.stat().st_size}/{(Consts.min_size * size)})"
+      if err is not None:
+        self.logger.error(f"Mux error ({videos[0]['group']}): {err}\n{merge['err']}\n{merge['out']}")
+        continue
+
+      # Set tag
+      if not self.tags.set_int(temp, Consts.mux_tag, Consts.mux_vers):
+        continue
+
+      # Remove sources
+      try:
+        for source in (videos + subs):
+          self.logger.info(f"Removing: {source['path'].name}")
+          source['path'].unlink()
+          media.remove(source)
+      except BaseException as e:
+        self.logger.error(f"Unlink error: {e}")
+        continue
+
+      # Rename
+      err = None
+      try:
+        self.logger.info(f"Renaming: {temp.name} -> {final.name}")
+        temp.rename(final)
+        info = Consts.default()
+        info['path']     = final
+        info['name']     = final.name
+        info['ext']      = 'mkv'
+        info['group']    = videos[0]['group']
+        info['mux_vers'] = Consts.mux_vers
+        media.append(info)
+      except BaseException as e:
+        err = e
+      if not final.exists():
+        err = 'file missing'
+      elif final.stat().st_size < (Consts.min_size * size):
+        err = 'file too small'
+      if err is not None:
+        self.logger.error(f"Rename error ({final}): {err}")
+        continue

+ 49 - 0
zz_remux/zz_remux/probe.py

@@ -0,0 +1,49 @@
+import json, logging
+import pprint
+from .proc import Proc
+
+class Probe:
+  ffprobe_exec = 'ffprobe'
+  
+  def __init__(self, ffprobe_exec='ffprobe', logLevel=logging.WARN):
+    self.logger = logging.getLogger(__name__)
+    self.logger.setLevel(logLevel)
+
+    # Module init
+    self.ffprobe_exec = ffprobe_exec
+    self.proc = Proc(logLevel=logLevel)
+
+  def probe(self, path):
+    self.logger.debug(f"Probing: {path}")
+
+    # Sanity
+    if not path.exists():
+      self.logger.warn(f"No such file: {path}")
+      return {}
+
+    # ffprobe
+    probe = self.proc.run([
+      self.ffprobe_exec,
+      '-hide_banner', '-show_streams',
+      '-print_format', 'json',
+      path
+    ])
+
+    # Parse and verify
+    err = None
+    streams = None
+    try:
+      streams = json.loads(probe['out'])['streams']
+    except:
+      pass
+    if not streams or len(streams) < 1:
+      err = f"Unable to parse: {path}"
+    elif 'index' not in streams[0]:
+      err = f"Stream missing index: {path}"
+    if err is not None:
+      self.logger.error(err)
+      return {}
+
+    # Return
+    self.logger.debug(f"Streams: {streams}")
+    return streams

+ 48 - 0
zz_remux/zz_remux/proc.py

@@ -0,0 +1,48 @@
+import subprocess, logging
+
+class Proc:
+	encoding = 'utf-8'
+
+	def __init__(self, stdout=subprocess.PIPE, stderr=subprocess.PIPE, logLevel=logging.WARN):
+		self.logger = logging.getLogger(__name__)
+		self.logger.setLevel(logLevel)
+
+		self.stdout = stdout
+		self.stderr = stderr
+
+	def run(self, cmd):
+		# Return dict
+		ret = {'out': None, 'err': None, 'code': int(-111) }
+
+		# Sanity
+		if not cmd or len(cmd) < 1:
+			self.logger.error(f"Empty cmd")
+			return ret
+
+		# Exec
+		self.logger.debug(f"Cmd: {cmd}")
+		proc = subprocess.run(cmd, stdout=self.stdout, stderr=self.stderr)
+		self.logger.debug(f"Result: {proc}")
+
+		# Exit code
+		try:
+			ret['code'] = proc.returncode
+		except:
+			pass
+
+		# Decode outputs
+		if hasattr(proc, 'stdout') and len(proc.stdout) > 0:
+			try:
+				ret['out'] = proc.stdout.decode(self.encoding)
+			except:
+				pass
+		if hasattr(proc, 'stderr') and len(proc.stderr) > 0:
+			try:
+				ret['err'] = proc.stderr.decode(self.encoding)
+			except:
+				pass
+
+		# Return
+		if ret['code'] != 0:
+			self.logger.error(f"Error {cmd[0]}({ret['code']}): {ret['err']}")
+		return ret

+ 77 - 0
zz_remux/zz_remux/scan.py

@@ -0,0 +1,77 @@
+import sys, re, pathlib, logging
+import langcodes
+from .consts import Consts
+from .tag import Tag
+from .mux import Mux
+from .lang import Lang
+
+class Scan:  
+  def __init__(self, logLevel=logging.WARN):
+    # Logging
+    self.logger = logging.getLogger(__name__)
+    self.logger.setLevel(logLevel)
+
+    # Tag init
+    self.tags = Tag(logLevel=logLevel)
+
+    # Build metadata-parsing filename regex
+    self.re_media = re.compile(
+      r'^(.*?)' +
+      r'(?:\.(' + '|'.join(Consts.subs_lang)  + r'))?' +
+      r'(?:\.(' + '|'.join(Consts.subs_sdh)   + r'))?' +
+      r'(?:\.synced)?' +
+      r'\.('    + '|'.join(Consts.media_ext)  + r')$'  ,
+      re.IGNORECASE
+    )
+
+  def scan(self, files, parent=pathlib.Path(''), match=None):
+    self.logger.info(f"Processing {len(files)} files")
+    media = []
+
+    # Scan this directory for media
+    for file in files:
+      self.logger.info(f"Found file: {file}")
+      
+      info = Consts.default()
+      info['path'] = parent.joinpath(file)
+      info['name'] = file
+
+      # Parse media paths
+      m = self.re_media.search(info['name'])
+      if m and len(m.groups()) == 4:
+        info['group'] = m.group(1)
+
+        try:
+          lang = langcodes.Language.get(m.group(2).lower())
+          info['lang'] = lang.to_alpha3()
+        except:
+          if m.group(2):
+            logger.warning(f"Unable to match language code: {m.group(2)}")
+
+        if m.group(3):
+          info['sdh'] = m.group(3).lower() in Consts.subs_sdh
+
+        info['ext'] = m.group(4).lower()
+      del m
+
+      # Skip non-media files (and parsing errors)
+      if not info['ext']:
+        self.logger.debug(f"Skipping non-media file: {file}")
+        continue
+
+      # Skip files that don't match the user regex
+      if match:
+        if not match.search(info['group']):
+          self.logger.info(f"Skipping non-matching group: {info['group']}")
+          continue
+
+      # Read tags
+      info['mux_vers']  = self.tags.get_int(info['path'], Consts.mux_tag)
+      info['lang_vers'] = self.tags.get_int(info['path'], Consts.lang_tag)
+
+      # Loop end
+      media.append(info)
+      self.logger.info(f"Found media: {info}")
+
+    # Return
+    return media

+ 77 - 0
zz_remux/zz_remux/tag.py

@@ -0,0 +1,77 @@
+import pathlib, logging
+import xattr
+
+class Tag:
+  # Internals
+  _encoding = 'utf-8'
+
+  def __init__(self, logLevel=logging.WARN):
+    self.logger = logging.getLogger(__name__)
+    self.logger.setLevel(logLevel)
+
+  def get_int(self, path, attr):
+    ret = self.get_bytes(path, attr)
+    if ret:
+      try:
+        ret = int.from_bytes(ret)
+      except:
+        self.logger.error(f"Unable to decode int ({path}): {attr} => {ret}")
+    return ret
+
+  def get_str(self, path, attr):
+    ret = self.get_bytes(path, attr)
+    if ret:
+      try:
+        ret = ret.decode(self._encoding)
+      except:
+        self.logger.error(f"Unable to decode str ({path}): {attr} => {ret}")
+    return ret
+
+  def get(self, path, attr):
+    return self.get_str(path, attr)
+
+  def get_bytes(self, path, attr):
+    value = None
+    try:
+      if attr in xattr.listxattr(path):
+        value = xattr.getxattr(path, attr)
+        self.logger.debug(f"Got tag ({path}): {attr} => {value}")
+    except BaseException as e:
+      self.logger.error(f"Get error ({path}:{attr}): {e}")
+    return value
+
+  def set_bytes(self, path, attr, value):
+    try:
+      xattr.setxattr(path, attr, value)
+      self.logger.debug(f"Wrote tag ({path}): {attr} => {value}")
+      return True
+    except BaseException as e:
+      self.logger.error(f"Set error ({path}:{attr}): {e}")
+      return False
+
+  def set_int(self, path, attr, value):
+    try:
+      bval = value.to_bytes()
+      return self.set_bytes(path, attr, bval)
+    except:
+      self.logger.error(f"Unable to encode int: {value}")
+      return False
+
+  def set_str(self, path, attr, value):
+    try:
+      bval = value.encode(self._encoding)
+      return self.set_bytes(path, attr, bval)
+    except:
+      self.logger.error(f"Unable to encode str: {value}")
+      return False
+
+  def set(self, path, attr, value):
+    if isinstance(value, bytes):
+      return self.set_bytes(path, attr, value)
+    elif hasattr(value, 'to_bytes'):
+      return self.set_int(path, attr, value)
+    elif isinstance(value, str):
+      return self.set_str(path, attr, value)
+    else:
+      self.logger.error(f"Unable to encode as a byte sequence: {value}")
+      return False