|
|
||
|---|---|---|
| .gitignore | ||
| LICENSE | ||
| link_specials.py | ||
| movie_map.py | ||
| README.md | ||
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 / withls; it's the show folder that contains aSeason 00,Season 0, orSpecialssubfolder.episodes— theS00Exxnumbers. 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 exactMovie Name (Year)here. Always re-run--dry-runafter 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 inmovie_map.py(or via thePLEX_URLenv var /--plex-urlflag) 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_TOKENenvironment variable, or from a file namedplex_token.txtplaced beside the script (this filename is git-ignored so it won't be committed). To find your token: in Plex, open any item → ⋯ → Get Info → View XML, and copy theX-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 whichSeason 00episodes 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/ffsubsyncare 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-runfirst, 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
--quietso the job log only contains warnings, duplicate notices, and the summary. - If you want the automatic Plex rescan, make sure
PLEX_URLand a token are set (see Personalize); otherwise add--no-plex.
Good to know
- Always preview after editing the map. A
--dry-runshows you the exact titles and links before anything is written; add atitlesoverride anywhere a derived name looks wrong. - Same filesystem required. Hardlinks only work when the source and the
Specials to Moviesdestination 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.