Skip to main content

Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror

Project description

Bidirectional Movie Library Sync for Plex and Jellyfin

Can't decide between Jellyfin and Plex? This tool might help. It synchronizes your movie library between Jellyfin and Plex formats in both directions — without duplicating any files. Instead, it uses hardlinks to mirror your collection efficiently, saving storage while keeping both libraries in sync.

Overview

The script scans the source library, parses each movie folder for metadata (title, year, optional provider ID), and reproduces the same directory structure in the target location. Rather than copying video files, it creates hard links to avoid extra storage usage. Asset folders (e.g., extras, subtitles) are also mirrored. With --delete, any files or folders in the target that are no longer present in the source will be removed.

Warning: This script will overwrite the entire target directory. Do not store or edit anything manually in the target library path. The source library is treated as the only source of truth, and any unmatched content in the target folder may be deleted without warning.

Note: This tool is only useful if your media library is well-maintained and each movie resides in its own folder.

⚠️ Movies only: This script is designed exclusively for movie libraries. It does not support TV shows or miniseries. However, this is usually not a limitation in practice: for shows, Jellyfin and Plex use very similar directory structures, so you can typically point both apps to the same library without issues.

⚠️ Hardlinks require a shared filesystem: Source and target paths must live on the same filesystem. Hardlinks cannot span filesystems or physical disks. On Unraid's classic array this is a real concern — see the Unraid section for details before running this tool there.

Installation

Python package (recommended)

The easiest way to install the CLI is via uv:

uv tool install jellyplex-sync

This places jellyplex-sync on your PATH in an isolated environment. pipx install jellyplex-sync works the same way if you prefer pipx.

Docker

A prebuilt container image is published to GitHub Container Registry:

ghcr.io/sniner/jellyplex-sync:latest

If you'd rather build it yourself:

docker build -t jellyplex-sync .

Usage

CLI

jellyplex-sync [OPTIONS] /path/to/source/library /path/to/target/library

The first positional argument is the source library, the second is the target. By default the tool auto-detects whether the source is a Jellyfin or Plex layout and converts it to the other format.

Options

  • --create — create the target directory if it doesn't exist.
  • --delete — remove movie folders and stray files in the target that are not present in the source.
  • --dry-run — show what would happen without touching the filesystem.
  • --verbose — log every processed movie.
  • --debug — enable debug-level logging.
  • --convert-to {jellyfin,plex,auto} — force conversion direction. auto (default) inspects the source library and picks the appropriate target format.

Examples

Mirror a Jellyfin library into a new Plex structure:

jellyplex-sync --create ~/Media/Jellyfin ~/Media/Plex

Mirror and prune anything in the Plex folder that no longer exists in Jellyfin:

jellyplex-sync --delete ~/Media/Jellyfin ~/Media/Plex

Dry-run a verbose, full sync with deletion:

jellyplex-sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex

Docker

docker run --rm -v /your/media:/mnt ghcr.io/sniner/jellyplex-sync:latest /mnt/source /mnt/target

The repo ships with a small demo library you can use to try the tool:

docker run --rm -v .:/mnt ghcr.io/sniner/jellyplex-sync:latest \
    /mnt/DEMO_PLEX_LIBRARY/Movies /mnt/DEMO_PLEX_LIBRARY/Jellyfin

Bind-mount note: Both source and target paths must be reachable inside the container through the same bind mount, otherwise hardlinks between them cannot be created.

Unraid (User Scripts)

The repository includes a jellyplex-sync.sh helper you can add to the Unraid User Scripts plugin. It pulls the latest container image, removes outdated ones, and runs the sync. Adjust the source and target paths at the bottom of the script to match your library locations.

⚠️ Dry-run by default: The script ships with --dry-run enabled. It will only print what it would do — nothing changes on disk until you remove that flag.

⚠️ Array layout matters: Hardlinks only work within a single filesystem. On Unraid's classic md-array, paths under /mnt/user/... are served by shfs, which can spread files across multiple disks. The result is that hardlinks created across /mnt/user/... paths can silently fall back to copies, get broken when the mover relocates files between cache and array, or fail outright. For reliable operation on Unraid, use one of these layouts:

  • Same disk: Put both source and target under the same /mnt/diskX/... path so the kernel sees one filesystem.
  • Cache pool: Keep both libraries on a single cache pool with no mover involvement.
  • ZFS pool (recommended): ZFS-backed pools present a single filesystem and handle hardlinks cleanly.

If you used the legacy single-file script from the unraid_user_scripts branch in the past, the same constraint applied there.

This helper can also be used on other NAS systems or Linux servers — schedule it via cron for unattended syncs. Docker must be installed.

Behavior

  • Hardlinks — Video files are linked, not copied. Both libraries reference the same physical files on disk.
  • Asset folders — Subdirectories (e.g., other, interviews) are processed recursively with the same hardlink logic. Note: rename your Jellyfin extras folder to other, since Plex does not recognize extras.
  • Stray items — With --delete, any file or folder in the target that has no counterpart in the source is removed.

Library layouts

Jellyfin

This is the expected folder structure in a Jellyfin movie library. The parser relies on it being consistent:

Movies
├── A Bridge Too Far (1977) [imdbid-tt0075784]
│   ├── A Bridge Too Far (1977) [imdbid-tt0075784].mkv
│   └── trailers
│       └── A Bridge Too Far.mkv
└── Das Boot (1981) [imdbid-tt0082096]
    ├── Das Boot (1981) [imdbid-tt0082096] - Director's Cut.mkv
    ├── Das Boot (1981) [imdbid-tt0082096] - Theatrical Cut.mkv
    └── other
        ├── Production Photos.mkv
        └── Making of.mkv

Each movie must reside in its own folder, with optional subfolders for extras. Different editions (e.g., Director's Cut, Theatrical Cut) must be named accordingly.

Special filename handling

Jellyfin doesn't distinguish between editions (e.g., Director's Cut) and versions (e.g., 1080p vs. 4K). To work around this, I appended tags like "DVD", "BD", or "4K" to filenames in my personal library, ensuring the highest quality appears first and is selected by default in Jellyfin. Plex, on the other hand, supports editions natively and handles different versions via naming patterns and its internal version management. These specific tags are converted into Plex versions, while all other suffixes are treated as editions.

This naming convention is something I came up with for my personal library — it's not part of any official Jellyfin standard. If your setup uses a different scheme, you may want to adjust the parsing behavior by switching to a different VariantParser, such as the simpler SimpleVariantParser.

Plex

Plex follows a more structured naming convention than Jellyfin. While Jellyfin typically appends edition or variant information using a - (space-hyphen-space) pattern, Plex supports additional metadata inside curly braces for editions and square brackets for versions or other details.

Unlike Jellyfin, Plex's naming system allows you to embed extra tags such as release source ([BluRay]), quality ([4K]), or codec ([HEVC]) directly in the filename. These tags are ignored by the default Plex scanners during media recognition, but remain visible in the interface — which makes them useful for organizing your collection without affecting playback or matching.

Note: This behavior applies to Plex's default scanner. If you use custom scanners or agents, they may treat these tags differently.

I originally started with a Jellyfin-style library and converted it to be Plex-compatible. Over time, I came to prefer Plex's more expressive naming conventions and switched my personal collection to follow the Plex format. I now use Jellyfin mainly as a fallback for long-term archival and offline use.

This is the expected folder structure in Plex format (with some demo tags):

Movies
├── A Bridge Too Far (1977) {imdb-tt0075784}
│   ├── A Bridge Too Far (1977) {imdb-tt0075784}.mkv
│   └── trailers
│       └── A Bridge Too Far.mkv
└── Das Boot (1981) {imdb-tt0082096}
    ├── Das Boot (1981) {imdb-tt0082096} {edition-Director's Cut} [1080p].mkv
    ├── Das Boot (1981) {imdb-tt0082096} {edition-Theatrical Cut} [1080p][remux].mkv
    └── other
        ├── Production Photos.mkv
        └── Making of.mkv

License

This project is licensed under the BSD 3-Clause License.

Disclaimer

This is a private project written for personal use. It doesn't cover all use cases or environments. Use at your own risk. Contributions or forks are welcome if you want to adapt it to your own setup.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

jellyplex_sync-0.1.6.tar.gz (12.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

jellyplex_sync-0.1.6-py3-none-any.whl (16.4 kB view details)

Uploaded Python 3

File details

Details for the file jellyplex_sync-0.1.6.tar.gz.

File metadata

  • Download URL: jellyplex_sync-0.1.6.tar.gz
  • Upload date:
  • Size: 12.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for jellyplex_sync-0.1.6.tar.gz
Algorithm Hash digest
SHA256 352c01b2d417f2c4fa5d11e507e8ced79deb1acb8288b4f6a92b45a5389b1f17
MD5 d998eafd534f7a02010d5b985d24dce1
BLAKE2b-256 5c8fc64a6a1e99dac861c931b7129bbfd65ae319f875b1144ec6177aa8b36850

See more details on using hashes here.

File details

Details for the file jellyplex_sync-0.1.6-py3-none-any.whl.

File metadata

  • Download URL: jellyplex_sync-0.1.6-py3-none-any.whl
  • Upload date:
  • Size: 16.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for jellyplex_sync-0.1.6-py3-none-any.whl
Algorithm Hash digest
SHA256 d2b23651853c259b091b5c44c488d5306976b42529f510f2fad19007d5d5d449
MD5 03ce71467d7f1194c31af6e73e4d19a5
BLAKE2b-256 629ca5c626ec6d971a5e141129d1981ace775b5740e6e27a392006bbffb39b7c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page