plex-renamer
Renames a local media volume to the naming standard Plex publishes, with a full SQLite journal so any run can be replayed in reverse.
The rules are taken from Plex's own support articles. The distilled ruleset is
kept in reports/plex-spec.json as a reference, with the source article for each
section; the code does not read it. Of its 260 rules, 155 are marked
verified: true and 105 verified: false.
Guarantees
- Two kinds of file are deleted, and undo writes them back. These are
torrent-site ads (a text, picture, link, page, info or program file whose
name starts with the name of a torrent site, such as
YTS.MX - Official site.jpg) and.DS_Storefiles, known by name and by the first bytes of that format. A poster or an info file named after a release only carries a site's tag, so a name with a year, a resolution, a codec or a source in it is never an ad. Each is a plain file of at most 1 MiB, and its bytes, modification time and mode go into the journal beforeos.unlinkremoves it, soundowrites it back. A file that has changed since it was listed is not deleted. Folders whose names start with@,#or.@, which a NAS keeps for itself, are never looked into. - Nothing else in the library is deleted. Apart from those two kinds, the
library is changed with
os.rename,os.mkdir,os.makedirsandos.rmdironly, andos.rmdironly runs on a folder that a listing shows to be empty. A barermdiris no guard: macOS removes a folder that holds only._files, and the files with it. On a volume that keeps a folder's attributes in a._file beside the folder, such as FAT, macOS removes that file with the folder, so a folder with such a file beside it stays. The only things removed are empty directories: ones the plan clears away, whichundorecreates, and ones a run created, once they are empty again. The plan clears away a folder the run empties, and an empty folder of a title that already has its proper folder. A folder that is already at a planned path when the run gets to it is not the run's, andundoleaves it. When a folder has to go, its folder metadata (.DS_Store,Thumbs.db) moves up to the parent first (as.DS_Store-1and so on when the name is taken). A.DS_Storeis then deleted with the ads, and aThumbs.dbstays. A server can keep files of its own hidden from the share, such as thumbnail caches. The tool never sees those, and what becomes of them when their folder is removed is up to the server. - Besides those, the one thing the tool deletes is its own. To estimate the running time,
applymakes a new folder at the volume root (.plexrenamer-benchmark-and a random suffix), writes 60 scratch files into it, times renaming them and removes those files and that folder again, also on--dry-run. It removes nothing it did not make there, and a folder that holds anything else stays.--rateskips it. - A lookup that fails is not an answer. On a share, a lookup can fail for a
moment. Right before an operation and in
undo, the tool never takes that to mean that a path is free or that a file is gone: the operation in hand fails and says why. While the plan is made, such a lookup can make the plan refuse to start or leave something out, and the checks right before each operation catch the rest. Whenundoorverifycannot read the library to look a run over, it stops, changes nothing and says so. - Every run can be undone, also one that was cut off, and also after a remount.
The whole plan is in the SQLite journal before the first operation, each rename
with the size and inode of its source, each deleted file with its bytes before
it goes, and each operation is marked as started
before it runs and as done after.
undogoes through the journal backwards and settles every row by where its file is now, found by name, kind and size. That wayundoalso reverses an operation that was in flight when the run stopped (Ctrl-C, a kill, a crash), one whose mark never reached the journal, and a rename that went through although the share reported an error. The inode is extra proof while the journal's numbers still tell files apart. A network share can number its files anew on every mount, or work each number out from the path, so that every rename changes it. The files the run moved show whether it has, and then the inode is not asked for. On such a share a rename whose reply was lost needs a size to go by: for a folder or an empty file it is reported and stays as it is. Whenapplyorprunemoves folder metadata, that move is in the journal too. - Undo does not guess and does not give up. It never moves a file onto an
original path that has been taken, moves back only what it finds by name, kind
and size where the run was to put it, and keeps a folder a run created while
anything else is in it. A rename that
applyrefused before trying it, because its source was gone or its destination taken, never touched the disk, andundoleaves it alone. It stops when the library is not mounted. A row it could not undo keeps its state and is tried again by the nextundo, which is also how anundothat was itself cut off is finished. A run is marked undone only when every row is reversed or shown not to have happened, andundothen leaves it alone. Runs are undone newest first: plainundopicks the newest run that still stands, andundo --run Nrefuses while a later one does, unless--forceis given. - What undo cannot do. It cannot bring back what is no longer there: a file
that was deleted, moved elsewhere or replaced since the run stays as a failed
row, and the run stays partly undone. So does a folder the run removed whose
name something else has taken since: it is not made again. A deleted file is
written back only where its path is free: a file there now, such as a
.DS_StoreFinder has written again, stays, and the row waits with the bytes in the journal. Until a deleted file is back, the rows that would move or remove its folder wait too, and a file whose folder is gone is not written back: the folder is not made again. Anundothat is killed while it writes a file back can leave a.plexrenamer-restore-file beside it, which keeps that folder in place until it is removed by hand. An older run can then still be undone by naming it with--runand adding--force; its rows on paths the later run still stands on wait until that run is undone. A folder's own attributes, such as Finder tags or a comment, go with it when it is removed:undorecreates the folder without them. A folder and an empty file are known by name and kind alone. Where the inode is not asked, two things of one kind and size cannot be told apart either:- a file of the same name, kind and size cannot be told from the one the run moved;
- a move of a folder or an empty file that went through although the journal has it as failed is reported, not reversed;
- where a run was to move such look-alikes along one path (a swap, or a to b
and b to c), the journal decides: a row behind the last row that carries a
mark never ran, and a rename refused before it was tried did not happen. A
leg that failed but may have gone through all the same, as after a lost
reply, is reported and stays as it is. In a journal written before refused
renames were marked as such, so is every failed leg. Each mark is committed
on its own, so marks go missing only when the journal loses writes it had
committed: in a power cut, or when it is copied without its
-walfile. A swap or a cycle of look-alikes whose marks went missing can look as if it never got that far. Undo then leaves some or all of it where the run put it, and marks the run undone all the same.
- The plan is validated before anything moves. Two sources mapping to one
destination, a vanished source, or an already-occupied destination all stop
the run before it starts, unless
--forceis given. Even then nothing is overwritten: right before each rename its destination is looked at again, and a taken one fails that row. Each file is also looked at again right before its own move: one that has changed kind or size since the plan, or is no longer the planned file by its inode where that tells, stays where it is. A file planned empty is checked by kind alone. - One writer at a time. The checks look at a path and then rename it, and nothing locks the library. Run the tool while nothing else writes to it.
- Pruning cannot escape the library. Empty-directory removal is bounded to the interior of the category directories, so it can never reach the volume root.
- Renames are ordered. A move whose destination is still occupied by another pending move is deferred; a genuine swap is staged through a temporary name.
Install
pipx install plex-renamer # or: pip install plex-renamer
It needs Python 3.12 or newer and nothing outside the standard library. The
installed command is plex-renamer, with the same sub-commands and flags as
python3 -m plexrenamer below. The journal (state/), the decisions and the
reports live in the folder it runs from, so run it from the same folder every
time, or point --db at the journal.
Each GitHub release carries the wheel and the sdist with a SHA256SUMS file;
sha256sum -c SHA256SUMS in the download folder checks them.
Usage
python3 -m plexrenamer plan # build and validate, write reports/
python3 -m plexrenamer apply --dry-run # op count, measured rate, ETA only
python3 -m plexrenamer apply # run it, with a progress bar
python3 -m plexrenamer verify # re-check the last run by name and size
python3 -m plexrenamer undo # reverse the newest run that stands
python3 -m plexrenamer undo --run 3 # reverse a specific run
python3 -m plexrenamer status # list runs and their op counts
python3 -m plexrenamer prune --dry-run # find shells emptied by a run
python3 -m plexrenamer clean --dry-run # list the ads and .DS_Store files
python3 -m plexrenamer clean # delete them, journalled
apply deletes the ads and .DS_Store files itself once every move has gone
through, in the same run, so one undo reverses both. clean does only that.
Useful flags: --root (default /Volumes/Multimedia), --categories to limit the
run, --episode-case lower to emit s01e01 instead of S01E01, --decisions to
point at a different override directory.
How it works
- scan walks the category directories read-only into a tree.
- classify decides whether each folder is a movie, a show, or a grouping folder holding several titles, and flags anything it is unsure about.
- decisions overlays per-title corrections from
decisions/*.json: canonical title, year, database ids, movie/show, editions, or skip. - planner turns each title into concrete file moves.
- executor validates, expands into
mkdir/rename/rmdiroperations, journals them, runs them with a progress bar, deletes the ads and.DS_Storefiles with their bytes in the journal, then checks that every file is where the journal says, by name, kind and size, and that every deleted file is gone. - undo settles every journal row against the disk, newest first.
Naming applied
| Case | Result |
|---|---|
| Movie | Category/Title (Year) {imdb-tt…}/Title (Year) {imdb-tt…}.ext |
| Movie edition | Title (Year) {edition-Final Cut}/Title (Year) {edition-Final Cut}.ext |
| Split movie | Title (Year) - part1.ext … part8 |
| Show | Category/Show (Year) {imdb-tt…}/Season 01/Show (Year) - S01E01 - Title.ext |
| Multi-episode file | Show (Year) - S02E01-E03.ext |
| Specials | Specials/Show (Year) - S00E01 - Title.ext |
| Subtitle | <video stem>.en.srt, .en.forced.srt, .en.sdh.srt |
| Extras | Behind The Scenes, Deleted Scenes, Featurettes, Interviews, Scenes, Shorts, Trailers, Other |
| Artwork | poster.jpg, background.jpg |
| DVD rip | Title (Year)/VIDEO_TS/… kept intact |
| Junk | dot-prefixed so the scanner skips it; .hidden- in front where a dot alone gives a name the system owns (.DS_Store, ._…) or the name starts with hidden-; ads and .DS_Store files are then deleted |
Deliberate deviations from the published examples
S01E01, nots01e01. Plex's examples are lowercase and the token is matched case-insensitively. Uppercase matches what every mainstream tool already uses.--episode-case lowerswitches it.- A regional label before the language code (
Movie (2019).Brazilian.pt.srt). Plex documents onlyname.lang.ext, which would collide when a release ships several variants of one language. The label form is widely supported and is the only way to keep the distinction. It is only used from the third file of a language on: the first two take the two code widths Plex documents,.pt.srtand.por.srt, so.eng.srtbeside.en.srtis intended. - A three-letter tag the tool does not know is kept as the language code.
Release tools name subtitle files by ISO-639-2 code, so
xyz.srtbecomesMovie (2019).xyz.srt, and lint lists every such code. A name with no language in it, or withund(undetermined), is filed as English. - One id tag per title. Plex documents
{imdb-…},{tmdb-…}and{tvdb-…}as alternatives, so exactly one is emitted, preferring IMDb.
Tests
python3 -m unittest discover -s tests -v
Covers the naming patterns, plan validation, cycle breaking, the apply/undo
round trip byte for byte, and an explicit assertion that no file content ever
disappears from the volume. The recovery tests cut apply and undo off at
every single step, before and after the step touches the disk, and check that
the tree comes back exactly, folder metadata included, also when the tree has
been swapped for a copy with new inode numbers in between, which is what a
remount does. The safety tests put somebody else's files in the way, take the
library away, lose a reply from the share and undo runs out of order, and check
that no foreign file is touched and that every row that could not be undone is
still there for the next try. The share tests put the library on the inode
numbers an SMB share was seen to hand out on macOS: numbers above 2^63 - 1,
which are more than an SQLite integer holds, and numbers worked out from the
path, which change with every rename. On the second kind the round trip,
recovery and safety tests run a second time, apart from two safety tests whose
promise rests on an inode that holds: there each is replaced by a test of what
is left of it. The look-alike tests do the same with swaps and chains of files
of one size, folders and empty files, cut off at every step, and undo an older
run with --force while a later run on its paths still stands. The lookup tests
make single lookups fail where a decision rests on them, and remove folders the
way macOS does, also on a FAT volume. The delete tests check that only ads and
real .DS_Store files go and nothing that changed since the listing, and that
undo writes each one back byte for byte with its time and mode: after a lost
reply, after a cut on either side of the delete, after a remount, and never
over a file that has taken its place.
CI runs them on macOS, on Python 3.12 to 3.15, for every pull request and every push to main.
Pruning shells left behind by a rename
python3 -m plexrenamer prune --dry-run # list what would go
python3 -m plexrenamer prune # remove them, journalled
prune removes directories that hold no media anywhere below them, but only
those this tool actually emptied, established from the journal rather than
guessed. A folder that was already empty before the tool ever ran is reported
and left alone. Any folder metadata inside a pruned directory is moved to the
parent rather than deleted, and undo recreates the directory and puts that
file back. A directory that still holds anything else, ._ files included, is
kept and reported.
Releases
A release is a tag vX.Y.Z on main that matches __version__ in
plexrenamer/__init__.py. Pushing it runs release.yml: the tests and the
package build first, then the GitHub release with the wheel, the sdist and
their SHA256SUMS, then the upload of the same files to PyPI through Trusted
Publishing. The release refuses a tag that is not on main, or that moved after
the run started.
Metadata
Release files for plex-renamer 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| plex_renamer-1.0.0.tar.gz | 99.2 kB | With provenance | Details for plex_renamer-1.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| plex_renamer-1.0.0-py3-none-any.whl | Python 3 | none | any | Details for plex_renamer-1.0.0-py3-none-any.whl |
Total release size: 164.1 kB
Release files / plex_renamer-1.0.0.tar.gz
| Download URL | plex_renamer-1.0.0.tar.gz |
|---|---|
| Size | 99.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
323831e8aa6bbc54f3354b06d17fcb081daabdde9bda2b3e85ca85bbf88063f6
|
|
BLAKE2b-256 checksum How to use checksums |
7c577f6820e030bd6aa70f542dbdec0d74925660ab03426196a434c5f81b4c35
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 13, 2026.
Transparency logRelease files / plex_renamer-1.0.0-py3-none-any.whl
| Download URL | plex_renamer-1.0.0-py3-none-any.whl |
|---|---|
| Size | 64.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
27fd91e94854c98f2ce0e850d4c70a6d2589fd904a43906b4c692069180bc91a
|
|
BLAKE2b-256 checksum How to use checksums |
91816ef7619b0102871e476656be689a71747344534c4efe4288f532c6200337
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 13, 2026.
Transparency logSecurity for plex-renamer 1.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for plex_renamer-1.0.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 13, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.