A tiny CLI to manage media libraries: migrate by TVDB IDs, rename seasons and episodes.
The CLI is subcommand-based with the following utilities:
migrate— move items from library-a to library-c when the TVDB ID exists in library-bseason-renamer— renames season folders in a library to the canonical 'Season NN' formepisode-renamer— renames episode files to<Show (Year)> - sNNeMM[ -ePP].extepisode-check— Compare local episode counts with TVDB dataprepare— organise loose TV episode files intoSeason NNfolders and rename them toShow (Year) - eEE sSS.ext
Additionally, there is a help subcommand that prints a short, human-friendly summary of available commands and their one-line descriptions.
Note: calling plex-leon with no subcommand will now launch the interactive menu by default. The menu lets you select a discovered utility and provides prompts for its arguments.
You can get detailed, command-specific help two ways:
- Using the
helpsubcommand:
plex-leon help migrate- Or using the built-in argparse help for any subcommand:
Compare the number of episodes in your local TV show library with the episode counts from TVDB (The TV Database). Useful to identify shows with missing episodes.
How it works:
- Scans the library for show folders containing TVDB IDs:
Show Name (YYYY) {tvdb-12345}. - Counts media files in
Season NNfolders (ignores non-media files andSeason 00specials). - Fetches episode counts per season from TVDB for the matched series.
- Prints a per-season comparison table highlighting discrepancies.
Options:
--lib PATH— Path to the library to check (default:./data/library-p)
Prerequisites:
- A TVDB API key must be available via the
TVDB_API_KEYenvironment variable.
Examples:
# With Poetry (for development)
poetry run plex-leon episode-check
# If installed globally via pip (recommended)
plex-leon episode-check
# Specify a different library
poetry run plex-leon episode-check --lib /path/to/libraryplex-leon migrate --help
Both will print detailed usage and options for the selected utility.
Note: commands are discovered automatically from the utility classes, so adding a new utility will make it appear in the CLI and help output without manual changes to this README.
Returns 0 on normal completion, 2 if required external tools are missing (preflight check fails). Prints detailed DECISION lines for eligible items (including resolution and size comparisons) and a final summary with total duration.
Move items from library-a to library-c when the TVDB ID exists in library-b.
How it works:
- TVDB IDs are extracted with a case-insensitive pattern:
{tvdb-<digits>}. Examples:John Wick (2014) {tvdb-155}.mp4→155Game of Thrones (2011) {TVDB-121361}→121361
- Hidden entries (starting with
.) are ignored. - Library-b is scanned recursively to support a production-like, bucketed layout under A–Z and
0-9(non-letter starters). Examples:library-b/A/Avatar (2009) {tvdb-19995}.mp4library-b/0-9/[REC] (2007) {tvdb-12345}.mp4library-b/0-9/2001 A Space Odyssey (1968) {tvdb-...}.mp4
- Library-a is scanned at the top level only (both files and folders).
- If an item in library-a has a TVDB ID that also exists anywhere under library-b, it's considered eligible and moved to library-c.
- For movies (files), the destination inside library-c depends on a comparison with the matching item in library-b:
better-resolution/when the source has a higher pixel count (width×height)greater-filesize/when resolution isn't higher but the source file is largerto-delete/when neither is true (i.e., the library-b item is as good or better) Resolution is read via ffprobe (FFmpeg) first, then mediainfo. If both resolutions are unknown, the tool falls back to file-size comparison only.
- For TV shows (folders), the tool compares episodes individually by matching season and episode numbers (e.g., s01e01) between library-a and library-b. Each episode is moved to the appropriate categorization folder in library-c (
better-resolution/,greater-filesize/, orto-delete/) based on the same resolution and size logic as for movies. The show/season/episode folder structure is preserved under the categorization folder. The show folder itself is not moved, only its episodes. - Moves print what would or did happen and end with a summary line including timing, for example:
Done. Eligible files/folders moved: X; skipped: Y. Took 2.34s.
Options:
--lib-a PATH— Source library (default:./data/library-a)--lib-b PATH— Reference library (default:./data/library-b)--lib-c PATH— Destination library (default:./data/library-c)--overwrite— Replace existing files/folders in library-c--dry-run— Show planned moves without changing the filesystem--threads N— Optional thread count for metadata reads (I/O bound). Moderate values (e.g., 4–8) are recommended to avoid disk thrash.--no-resolution— Skip resolution comparisons (size-only heuristic)
Performance notes:
- Resolution probing uses
ffprobe(FFmpeg) first and falls back tomediainfo; use--no-resolutionto skip and rely on size-only comparison. --threadswarms metadata reads in parallel for I/O-bound speedups.- When there is no counterpart found in library-b for a given item, resolution probing is skipped entirely to save time.
Examples:
# With Poetry (for development)
poetry run plex-leon migrate --dry-run
# If installed globally via pip (recommended)
plex-leon migrate --dry-run
# Specify custom paths and actually move files (Poetry)
poetry run plex-leon migrate --lib-a /path/a --lib-b /path/b --lib-c /path/c --overwrite
# Or, if installed globally:
plex-leon migrate --lib-a /path/a --lib-b /path/b --lib-c /path/c --overwrite
# Use 8 threads for faster metadata reads
poetry run plex-leon migrate --threads 8
# Or global:
plex-leon migrate --threads 8Renames season folders in a library to the canonical 'Season NN' form.
How it works:
- Renames season folders like 'season 01', 'Staffel 01', 'Satffel 01', or any folder with a single number to the canonical 'Season NN' form.
- Works recursively through the library.
- Supports typos and numbers >= 100.
- Only subfolders inside show folders are considered for renaming; top-level show folders (even if they contain digits, e.g., 'Game of Thrones 2011') are never renamed.
- For case-only renames (e.g., 'season 01' → 'Season 01'), a two-step swap is performed: first, the folder is renamed to
.plexleon_swap_Season NN, then toSeason NN. - If a canonical
Season NNalready exists, contents are merged non-destructively (conflicts are moved to a.plexleon_conflictssubfolder). - No folders or files are deleted or overwritten by default.
Options:
--lib PATH— Library path to process (required)--dry-run— Show planned renames without changing the filesystem
Examples:
# With Poetry (for development)
poetry run plex-leon season-renamer --lib ./data/library-b --dry-run
# If installed globally via pip (recommended)
plex-leon season-renamer --lib ./data/library-b --dry-run
# Actually rename all season folders in a library (Poetry)
poetry run plex-leon season-renamer --lib ./data/library-b
# Or global:
plex-leon season-renamer --lib ./data/library-bRenames episode files to the canonical format <Show (Year)> - sNNeMM[-ePP].ext.
How it works:
- The show title and year are taken from the parent show folder (e.g.,
Code Geass (2006) {tvdb-79525}→Code Geass (2006)). - The episode tag is parsed from the original filename (supports
s01e01,S01E01, and multi-episode ranges likeS01E01-E04) and normalized to lowercase. The full range is preserved in the output filename — e.g., a file taggedS01E01-E04becomesCode Geass (2006) - s01e01-e04.mkv. - Any additional episode title text in the filename is removed.
- Case-only changes (e.g.,
S01E01→s01e01) are performed via a safe two-step rename using a hidden swap file to avoid filesystem issues.
Options:
--lib PATH— Library path to process (required)--dry-run— Show planned renames without changing the filesystem
Examples:
# With Poetry (for development)
poetry run plex-leon episode-renamer --lib ./data/library-e --dry-run
# If installed globally via pip (recommended)
plex-leon episode-renamer --lib ./data/library-e --dry-run
# Actually rename episodes (Poetry)
poetry run plex-leon episode-renamer --lib ./data/library-e
# Or global:
plex-leon episode-renamer --lib ./data/library-eOrganise loose TV episode files into Season NN folders and rename them to Show (Year) - s01e01[-e04].ext.
How it works:
- Validates show folders: the folder name must contain a
{tvdb-...}suffix and must not have duplicate files claiming the same episode. - Organises loose TV episode files (files sitting directly in the show folder, not yet in a season
subfolder) into canonical
Season NNfolders. - Renames episodes to
<Show (Year)> - s01e01.ext(season before episode, zero-padded, lowercase). Multi-episode range files (e.g.S01E01-E04) produce a target name that preserves the full range:Show (Year) - s01e01-e04.ext. - Supported naming patterns in source files:
- Standard tags:
S01E02,s1e2,S01E02-E04(range → preserved in output) - German style:
Episode 12 Staffel 2orStaffel 2 Episode 12
- Standard tags:
- Only processes files that pass validation checks; shows with errors are skipped.
Options:
--lib PATH— Library path to process (required)--dry-run— Show planned changes without modifying the filesystem
Examples:
# With Poetry (for development)
poetry run plex-leon prepare --lib ./data/library-p --dry-run
# If installed globally via pip (recommended)
plex-leon prepare --lib ./data/library-p --dry-run
# Apply changes to data/library-p (Poetry)
poetry run plex-leon prepare --lib ./data/library-p
# Or global:
plex-leon prepare --lib ./data/library-p- Python 3.14+
- External tools on PATH (validated at startup):
ffprobe(from FFmpeg) andmediainfo
Below are a few supported ways to install the external tools and the Python package itself.
Debian / Ubuntu:
sudo apt-get update
sudo apt-get install -y ffmpeg mediainfomacOS (Homebrew):
brew update
brew install ffmpeg mediainfoWindows using winget (Windows Package Manager):
winget install Gyan.FFmpeg
winget install MediaArea.MediaInfoRecommended (global pip):
pip install plex-leon
# run the CLI directly without Poetry
plex-leon --helpFor development (Poetry):
# create virtualenv and install deps defined in pyproject.toml
poetry install
# run commands inside the virtualenv
poetry run plex-leon --helpAlternative (pip editable / development):
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e .
plex-leon --help- The CLI validates that
ffprobeandmediainfoare present on PATH at startup and will exit with code2if they are missing. - If you use Poetry the project is already configured; otherwise the
pip install -e .route will install the package in editable/development mode.
Follow these lightweight conventions when contributing and releasing:
- Semantic versioning: use tags like
v2.4.0for releases and increment MAJOR.MINOR.PATCH appropriately. - Commit messages: prefer a conventional commit style (e.g.,
feat:,fix:,chore:,test:). This keeps the changelog tidy.- When a change is scoped to a specific utility, prefer scoped commits, e.g.
feat(prepare):ortest(season-renamer):so the changed area is immediately obvious in the changelog.
- When a change is scoped to a specific utility, prefer scoped commits, e.g.
- Releases: update
README.mdandCHANGELOG.mdbefore tagging a release. Keep changelog entries under headings:Added,Changed,Fixed,Tests.
This repo can be used inside a development container (devcontainer) for a reproducible development environment. The devcontainer provides:
- a consistent Python 3.14 environment
- preinstalled development tools (linting, pytest, optional editors integration)
To use the devcontainer:
- Open the repository in VS Code with the Remote - Containers extension installed.
- Choose "Reopen in Container" from the Command Palette. VS Code will build the devcontainer image and open a workspace with all tools installed.
This project contains unit and integration tests under tests/.
Run all tests locally using Poetry (recommended):
poetry run pytest -qRun only unit tests:
poetry run pytest -q tests/unittestsThe integration tests include helper generators that can create sample library data under data/ for local testing. See tests/integration/generators for scripts and examples.
Note: in this project the "integration tests" are not standard pytest cases — they are generator scripts that create a test environment under data/ and let you run plex-leon against that environment to validate behavior.
How to use the integration generators locally:
- Create or activate your virtualenv and install the package (Poetry recommended):
poetry install
poetry run python tests/integration/generators/migrate_tlg.py
poetry run python tests/integration/generators/episode_renamer_tlg.py
poetry run python tests/integration/generators/season_renamer_tlg.py
poetry run python tests/integration/generators/prepare_tlg.py-
Inspect the generated folder (e.g.,
./data/library-a,./data/library-b, ...) to confirm files are present. -
Run plex-leon against the generated data (dry-run first):
poetry run plex-leon migrate --dry-run
poetry run plex-leon episode-renamer --dry-run
poetry run plex-leon season-renamer --dry-run
poetry run plex-leon prepare --dry-run- Validate the decisions printed by the tool (DECISION lines and final summary).
Note: when you use the provided test generators the utilities already default to the test library paths (for example prepare defaults to data/library-p). You therefore don't need to pass --lib flags when running the utilities against the generated data unless you put the generated data in a non-standard location.
Generate coverage reports (HTML + lcov + xml + json) with the project's Poetry script:
poetry run coverage
# results will be available under data/coverage/You can open data/coverage/html/index.html to view the HTML report in a browser.