Hardlink TV/anime Season-00 specials that are really movies into Plex movie folders
Find a file
xenarathon ffbfdad803 README: beginner-friendly rewrite with collapsible explainers + personalization steps
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:28:59 -04:00
.gitignore Clean rebuild each run + Plex rescan after linking 2026-06-14 20:42:48 -04:00
LICENSE Initial commit: show-movie-linker 2026-06-14 18:50:04 -04:00
link_specials.py Clean rebuild each run + Plex rescan after linking 2026-06-14 20:42:48 -04:00
movie_map.py Parameterize MEDIA_ROOT + PLEX_URL via env for public release 2026-06-15 21:37:57 -04:00
README.md README: beginner-friendly rewrite with collapsible explainers + personalization steps 2026-06-15 22:28:59 -04:00

show-movie-linker

Some TV shows have feature films filed away as Season 00 "specials" (the Stargate movies, the Pokémon movies, Psych: The Movie, Cowboy Bebop: The Movie, …). This tool finds those specials and links them into proper Movie folders so they also show up in your Plex Movie / Anime Movie libraries — looking like the films they actually are.

It does this with hardlinks, which means: no second copy on disk (the movie folder and the original episode share the exact same file), and your original episode is never moved, renamed, or deleted. It's safe to re-run — running it again just rebuilds the same links (idempotent), so it's fine on a schedule. It must be run as root (explained below), and it only ever writes inside its own Specials to Movies folder.

New to all this? No problem. The Quick start below is a few copy-paste steps, and every step has a click-to-expand explainer telling you what the command does and why.

What's a hardlink, and why does that mean "no extra disk space"?

A normal file is a name that points at the actual data on your disk. A hardlink is just a second name pointing at that same data. There's still only one copy of the movie on disk — now it simply answers to two names: one in your TV library (as an episode) and one in your Movie library (as a film).

Because there's only one real copy, the movie folder costs zero extra space, and Plex is happy to show the same file as an episode in the TV library and as a movie in the Movie library (they are different library types with different metadata). Delete one name and the other still works — the data only disappears when the last name is gone.

The one catch: a hardlink only works when both names live on the same filesystem. On a mergerfs pool (this setup) that's automatic, as long as the destination folder lives under the same pool.

Why does it have to run as root?

Modern Linux has a safety setting, fs.protected_hardlinks=1, that stops a normal user from hardlinking a file they don't own. Your media files are often owned by other accounts (Sonarr, Tdarr, etc.), so a non-root run would hit a "permission denied" error and quietly fall back to making a real copy — wasting disk space, exactly what we're trying to avoid.

The root user is exempt from that rule, so running as root lets it make true zero-space hardlinks for every file regardless of owner. The script prints a warning if you run it without root. (The OMV scheduled job runs as root, which is why this is fine in normal use.)


Quick start

The safest way to try it: run once in preview mode (--dry-run, which changes nothing and just prints what it would do), read the output, then run it for real.

First time using a terminal? Start here

A "terminal" is a window where you type commands instead of clicking buttons. On the server this is typically an SSH session — you log in to the machine that holds your media and type commands there.

To run a command, copy it, paste it into the terminal, and press Enter. You'll need Python 3 (already present on almost every Linux server — check with python3 --version) and root access (see the box above for why). On OMV / most Linux boxes you get root by putting sudo in front of a command, or by logging in as the root user.

1. Get the code

git clone https://git.ourserver.party/you/show-movie-linker.git
cd show-movie-linker
What did that do?

git clone downloads a copy of this project to the machine. cd show-movie-linker ("change directory") moves you into the folder it created, so the next commands run in the right place. Replace the URL with wherever you got this from (e.g. your own Forgejo/GitHub address).

2. Tell it where your media is and which specials are films

Open movie_map.py in a text editor. At the top, set MEDIA_ROOT to the folder that contains your TV Library / Anime Library folders. Below that is MAP — the curated list of which Season 00 episodes are actually movies. See Personalize it to your setup for exactly what to change and how.

3. Preview what it would do (changes nothing)

python3 link_specials.py --dry-run
What does this show me?

--dry-run is your safety net: it lists every show it's checking, the movie title it would use for each special, and every link it would create — but it writes nothing. Lines like + would link: … are the planned links. Read them and make sure the titles look right. If a title comes out garbled, add a titles override in movie_map.py (see Personalize). When it looks good, move on.

4. Run it for real (as root)

sudo python3 link_specials.py
Why sudo, and what will I see?

sudo runs the command as root so the hardlinks succeed for files owned by other accounts (see "Why does it have to run as root?" above). You'll see + linked: … lines and a summary at the end (newly linked, already present, etc.). Re-running is safe — anything already linked is counted as already present and left alone.

5. (Optional) Add the new folders to Plex and let it scan

Add Specials to Movies/Movie Specials to your Plex Movie library's folders, and Specials to Movies/Anime Movie Specials to your Anime Movie library. After that, each run can trigger a Plex rescan automatically — see Keep it running on a schedule.


Personalize it to your setup

Everything you need to change lives in movie_map.py. Here's each piece and how to find your own values.

MEDIA_ROOT — the folder that contains your library folders (TV Library, Anime Library, Movie Library, …). Defaults to /media; you can also override it without editing the file by setting the MEDIA_ROOT environment variable or passing --media-root /your/path.

How do I find my media root?

It's the parent folder that holds your TV Library and Anime Library folders — not an individual show folder. On this server the media pool is at /srv/mergerfs/media/Storage/Media, so a show like Storage/Media/TV Library/Psych {tvdb-79335} means MEDIA_ROOT is the part up to and including Media.

Tip: cd into a show's folder and run pwd to print its full path, then chop off the TV Library/<show> tail — what's left is your MEDIA_ROOT.

MAP — the curated list of which Season 00 specials are really films, one entry per show.

How do I add a show?

Each entry points at a show folder (relative to MEDIA_ROOT) and lists which Season 00 episodes are movies:

{"src": "TV Library/Psych {tvdb-79335}",   # folder under MEDIA_ROOT that holds "Season 00"
 "anime": False,                           # True -> Anime Movie Specials, False -> Movie Specials
 "mode": "include",                        # "include" = only the listed eps; "exclude" = all S00 EXCEPT them
 "episodes": ["E08"],                      # which Season 00 episode numbers are films
 "titles": {"E08": "Psych 3 This Is Gus (2021)"}},  # OPTIONAL: force the movie's name
  • src — find it in your file manager / with ls; it's the show folder that contains a Season 00, Season 0, or Specials subfolder.
  • episodes — the S00Exx numbers. Look at the special's filename (e.g. Show - S00E08 - …) to get the number.
  • titles — optional. If a derived name comes out wrong (or Plex would mismatch it), set the exact Movie Name (Year) here. Always re-run --dry-run after editing to check.
How do I find a show's correct movie title / year for an override?

The folder and filename Plex shows a movie under are taken from the titles value, formatted as Title (Year). Look the film up on TheMovieDB or IMDb to get the exact title and release year, and match the year Plex expects (the comments in movie_map.py note cases like Spider-Man (1977) where the year matters to avoid matching a different film).

PLEX_URL and your Plex token — only needed if you want the automatic Plex rescan after a run.

How do I set the Plex URL and token?
  • PLEX_URL — set it in movie_map.py (or via the PLEX_URL env var / --plex-url flag) to your Plex server, e.g. http://192.168.1.196:32400.
  • Token — never put it in the repo. The script reads it from the PLEX_TOKEN environment variable, or from a file named plex_token.txt placed beside the script (this filename is git-ignored so it won't be committed). To find your token: in Plex, open any item → ⋯ → Get InfoView XML, and copy the X-Plex-Token= value from the URL.

If no token is found, the script just skips the rescan and tells you so — everything else still works.


Options

All flags are optional. The defaults are the normal case (clean rebuild + Plex rescan on a live run).

Flag Default What it does
--dry-run (off) Preview only — print every link it would make and change nothing. Always do this first.
--quiet (off) Print only warnings, duplicate notices, and the final summary. Good for cron so logs stay short.
--media-root PATH from movie_map.py Override MEDIA_ROOT without editing the file.
--no-clean (off) Skip the wipe-and-rebuild of the destination folders. By default it rebuilds each run so stale links (left when Tdarr/Sonarr replace a source file) get cleared.
--no-plex (off) Don't trigger a Plex rescan after linking.
--plex-url URL from movie_map.py Your Plex base URL for the rescan, e.g. http://192.168.1.196:32400.
--plex-token TOKEN $PLEX_TOKEN or plex_token.txt Plex token for the rescan. Prefer the env var or the gitignored file over passing it on the command line.
--max-copy-mb N 8192 Safety cap. If a file genuinely can't be hardlinked (wrong filesystem) and is under this size, copy it instead; larger files are skipped and flagged so cron never silently duplicates a huge remux. Running as root avoids this path entirely.

How it works

  • movie_map.py — the curated list of which Season 00 episodes are actually films, per show, split into anime vs. regular.

  • link_specials.py — reads the map and hardlinks each selected special (plus its matching subtitle sidecars) into:

    <MEDIA_ROOT>/Specials to Movies/
    ├── Anime Movie Specials/   ← add to your Plex Anime Movie library
    └── Movie Specials/         ← add to your Plex Movie library
    

Each film lands in its own Title (Year)/Title (Year).ext folder. The title comes from a per-episode titles override if you set one, otherwise it's derived from the source filename (stripping the Show - S00Exx - prefix and quality/codec tags).

A few behaviors worth knowing:

  • Clean rebuild each run. It wipes and relinks the destination folders so stale links left when Tdarr transcodes or Sonarr upgrades a source file (which swaps the underlying file) are cleared automatically. Rebuilding is instant for hardlinks. Disable with --no-clean.
  • Picks the largest of duplicates. If a special has more than one file, the largest is linked and the rest are reported and skipped.
  • Subtitles come along. Sidecar subtitle files sharing the episode's name are linked too (one per language; auto-sync variants like alass/ffsubsync are skipped to avoid duplicates).
  • stdlib only. No pip install — runs on any Python 3.

Keep it running on a schedule

There's no daemon or container here — it's a plain script meant to be run by a scheduled (cron) job as root, so newly acquired films (e.g. ones Sonarr grabs later) get linked automatically. On OMV this is System → Scheduled Tasks: add a job that runs as the root user, on whatever cadence you like, with a command such as:

python3 /path/to/show-movie-linker/link_specials.py --quiet
What should I check before scheduling it?
  • Run it once by hand with --dry-run first, then once for real, and confirm the movies appear in Plex.
  • Schedule it to run as root (see the root box at the top) so every hardlink succeeds.
  • Use --quiet so the job log only contains warnings, duplicate notices, and the summary.
  • If you want the automatic Plex rescan, make sure PLEX_URL and a token are set (see Personalize); otherwise add --no-plex.

Good to know

  • Always preview after editing the map. A --dry-run shows you the exact titles and links before anything is written; add a titles override anywhere a derived name looks wrong.
  • Same filesystem required. Hardlinks only work when the source and the Specials to Movies destination are on the same filesystem. On a mergerfs pool this holds as long as the destination lives under the same pool.
  • It never touches your originals. The tool only writes inside Specials to Movies/; your TV and Anime library files are read-only to it.

License

MIT — see LICENSE. Do what you like with it.