plex-renamer 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ Metadata-Version: 2.5
2
+ Name: plex-renamer
3
+ Version: 1.0.0
4
+ Summary: Renames a local media volume to the naming standard Plex publishes, with a SQLite journal that can reverse any run.
5
+ Project-URL: Source, https://github.com/agusgonzaleznic/plex-renamer
6
+ Project-URL: Issues, https://github.com/agusgonzaleznic/plex-renamer/issues
7
+ Project-URL: Changelog, https://github.com/agusgonzaleznic/plex-renamer/releases
8
+ Author: Agustin Gonzalez Nicolini
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: media,naming,plex,rename,undo
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Multimedia :: Video
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+
20
+ # plex-renamer
21
+
22
+ Renames a local media volume to the naming standard Plex publishes, with a full
23
+ SQLite journal so any run can be replayed in reverse.
24
+
25
+ The rules are taken from Plex's own support articles. The distilled ruleset is
26
+ kept in `reports/plex-spec.json` as a reference, with the source article for each
27
+ section; the code does not read it. Of its 260 rules, 155 are marked
28
+ `verified: true` and 105 `verified: false`.
29
+
30
+ ## Guarantees
31
+
32
+ - **Two kinds of file are deleted, and undo writes them back.** These are
33
+ torrent-site ads (a text, picture, link, page, info or program file whose
34
+ name starts with the name of a torrent site, such as
35
+ `YTS.MX - Official site.jpg`) and `.DS_Store` files, known by name and by the
36
+ first bytes of that format. A poster or an info file named after a release
37
+ only carries a site's tag, so a name with a year, a resolution, a codec or a
38
+ source in it is never an ad. Each is a plain
39
+ file of at most 1 MiB, and its bytes, modification time and mode go into the
40
+ journal before `os.unlink` removes it, so `undo` writes it back. A file that
41
+ has changed since it was listed is not deleted. Folders whose names start
42
+ with `@`, `#` or `.@`, which a NAS keeps for itself, are never looked into.
43
+ - **Nothing else in the library is deleted.** Apart from those two kinds, the
44
+ library is changed with `os.rename`, `os.mkdir`, `os.makedirs` and `os.rmdir`
45
+ only, and `os.rmdir` only runs on a folder that a listing shows to be empty.
46
+ A bare `rmdir` is no
47
+ guard: macOS removes a folder that holds only `._` files, and the files with
48
+ it. On a volume that keeps a folder's attributes in a `._` file beside the
49
+ folder, such as FAT, macOS removes that file with the folder, so a folder with
50
+ such a file beside it stays. The only things removed are empty directories:
51
+ ones the plan clears away, which `undo` recreates, and ones a run created,
52
+ once they are empty again. The plan clears away a folder the run empties, and
53
+ an empty folder of a title that already has its proper folder. A folder that
54
+ is already at a planned path when the run gets to it is not the run's, and
55
+ `undo` leaves it. When a folder has to go, its folder metadata (`.DS_Store`,
56
+ `Thumbs.db`) moves up to the parent first (as `.DS_Store-1` and so on when
57
+ the name is taken). A `.DS_Store` is then deleted with the ads, and a
58
+ `Thumbs.db` stays. A server can keep files of its own hidden
59
+ from the share, such as thumbnail caches. The tool never sees those, and what
60
+ becomes of them when their folder is removed is up to the server.
61
+ - **Besides those, the one thing the tool deletes is its own.** To estimate the running time,
62
+ `apply` makes a new folder at the volume root (`.plexrenamer-benchmark-` and a
63
+ random suffix), writes 60 scratch files into it, times renaming them and
64
+ removes those files and that folder again, also on `--dry-run`. It removes
65
+ nothing it did not make there, and a folder that holds anything else stays.
66
+ `--rate` skips it.
67
+ - **A lookup that fails is not an answer.** On a share, a lookup can fail for a
68
+ moment. Right before an operation and in `undo`, the tool never takes that to
69
+ mean that a path is free or that a file is gone: the operation in hand fails
70
+ and says why. While the plan is made, such a lookup can make the plan refuse
71
+ to start or leave something out, and the checks right before each operation
72
+ catch the rest. When `undo` or `verify` cannot read the library to look a run
73
+ over, it stops, changes nothing and says so.
74
+ - **Every run can be undone, also one that was cut off, and also after a remount.**
75
+ The whole plan is in the SQLite journal before the first operation, each rename
76
+ with the size and inode of its source, each deleted file with its bytes before
77
+ it goes, and each operation is marked as started
78
+ before it runs and as done after. `undo` goes through the journal backwards and
79
+ settles every row by where its file is now, found by name, kind and size. That
80
+ way `undo` also reverses an operation that was in flight when the run stopped
81
+ (Ctrl-C, a kill, a crash), one whose mark never reached the journal, and a
82
+ rename that went through although the share reported an error. The inode is
83
+ extra proof while the journal's numbers still tell files apart. A network
84
+ share can number its files anew on every mount, or work each number out from
85
+ the path, so that every rename changes it. The files the run moved show
86
+ whether it has, and then the inode is not asked for. On such a share a rename
87
+ whose reply was lost needs a size to go by: for a folder or an empty file it
88
+ is reported and stays as it is. When `apply` or `prune` moves folder
89
+ metadata, that move is in the journal too.
90
+ - **Undo does not guess and does not give up.** It never moves a file onto an
91
+ original path that has been taken, moves back only what it finds by name, kind
92
+ and size where the run was to put it, and keeps a folder a run created while
93
+ anything else is in it. A rename that `apply` refused before trying it,
94
+ because its source was gone or its destination taken, never touched the disk,
95
+ and `undo` leaves it alone. It stops when the library is not mounted. A row it
96
+ could not undo keeps its state and is tried again by the next `undo`, which is
97
+ also how an `undo` that was itself cut off is finished. A run is marked undone
98
+ only when every row is reversed or shown not to have happened, and `undo` then
99
+ leaves it alone. Runs are undone newest first: plain `undo` picks the newest
100
+ run that still stands, and `undo --run N` refuses while a later one does,
101
+ unless `--force` is given.
102
+ - **What undo cannot do.** It cannot bring back what is no longer there: a file
103
+ that was deleted, moved elsewhere or replaced since the run stays as a failed
104
+ row, and the run stays partly undone. So does a folder the run removed whose
105
+ name something else has taken since: it is not made again. A deleted file is
106
+ written back only where its path is free: a file there now, such as a
107
+ `.DS_Store` Finder has written again, stays, and the row waits with the bytes
108
+ in the journal. Until a deleted file is back, the rows that would move or
109
+ remove its folder wait too, and a file whose folder is gone is not written
110
+ back: the folder is not made again. An `undo` that is killed while it writes
111
+ a file back can leave a `.plexrenamer-restore-` file beside it, which keeps
112
+ that folder in place until it is removed by hand. An older run can
113
+ then still be undone by naming it with `--run` and adding `--force`; its rows
114
+ on paths the later run still stands on wait until that run is undone. A
115
+ folder's own attributes, such as Finder tags or a comment, go with it when it
116
+ is removed: `undo` recreates the folder without them. A folder and an empty
117
+ file are known by name and kind alone. Where the inode is not asked, two
118
+ things of one kind and size cannot be told apart either:
119
+ - a file of the same name, kind and size cannot be told from the one the run
120
+ moved;
121
+ - a move of a folder or an empty file that went through although the journal
122
+ has it as failed is reported, not reversed;
123
+ - where a run was to move such look-alikes along one path (a swap, or a to b
124
+ and b to c), the journal decides: a row behind the last row that carries a
125
+ mark never ran, and a rename refused before it was tried did not happen. A
126
+ leg that failed but may have gone through all the same, as after a lost
127
+ reply, is reported and stays as it is. In a journal written before refused
128
+ renames were marked as such, so is every failed leg. Each mark is committed
129
+ on its own, so marks go missing only when the journal loses writes it had
130
+ committed: in a power cut, or when it is copied without its `-wal` file. A
131
+ swap or a cycle of look-alikes whose marks went missing can look as if it
132
+ never got that far. Undo then leaves some or all of it where the run put
133
+ it, and marks the run undone all the same.
134
+ - **The plan is validated before anything moves.** Two sources mapping to one
135
+ destination, a vanished source, or an already-occupied destination all stop
136
+ the run before it starts, unless `--force` is given. Even then nothing is
137
+ overwritten: right before each rename its destination is looked at again, and
138
+ a taken one fails that row. Each file is also looked at again right before its
139
+ own move: one that has changed kind or size since the plan, or is no longer
140
+ the planned file by its inode where that tells, stays where it is. A file
141
+ planned empty is checked by kind alone.
142
+ - **One writer at a time.** The checks look at a path and then rename it, and
143
+ nothing locks the library. Run the tool while nothing else writes to it.
144
+ - **Pruning cannot escape the library.** Empty-directory removal is bounded to the
145
+ interior of the category directories, so it can never reach the volume root.
146
+ - **Renames are ordered.** A move whose destination is still occupied by another
147
+ pending move is deferred; a genuine swap is staged through a temporary name.
148
+
149
+ ## Install
150
+
151
+ ```bash
152
+ pipx install plex-renamer # or: pip install plex-renamer
153
+ ```
154
+
155
+ It needs Python 3.12 or newer and nothing outside the standard library. The
156
+ installed command is `plex-renamer`, with the same sub-commands and flags as
157
+ `python3 -m plexrenamer` below. The journal (`state/`), the decisions and the
158
+ reports live in the folder it runs from, so run it from the same folder every
159
+ time, or point `--db` at the journal.
160
+
161
+ Each GitHub release carries the wheel and the sdist with a `SHA256SUMS` file;
162
+ `sha256sum -c SHA256SUMS` in the download folder checks them.
163
+
164
+ ## Usage
165
+
166
+ ```bash
167
+ python3 -m plexrenamer plan # build and validate, write reports/
168
+ python3 -m plexrenamer apply --dry-run # op count, measured rate, ETA only
169
+ python3 -m plexrenamer apply # run it, with a progress bar
170
+ python3 -m plexrenamer verify # re-check the last run by name and size
171
+ python3 -m plexrenamer undo # reverse the newest run that stands
172
+ python3 -m plexrenamer undo --run 3 # reverse a specific run
173
+ python3 -m plexrenamer status # list runs and their op counts
174
+ python3 -m plexrenamer prune --dry-run # find shells emptied by a run
175
+ python3 -m plexrenamer clean --dry-run # list the ads and .DS_Store files
176
+ python3 -m plexrenamer clean # delete them, journalled
177
+ ```
178
+
179
+ `apply` deletes the ads and `.DS_Store` files itself once every move has gone
180
+ through, in the same run, so one `undo` reverses both. `clean` does only that.
181
+
182
+ Useful flags: `--root` (default `/Volumes/Multimedia`), `--categories` to limit the
183
+ run, `--episode-case lower` to emit `s01e01` instead of `S01E01`, `--decisions` to
184
+ point at a different override directory.
185
+
186
+ ## How it works
187
+
188
+ 1. **scan** walks the category directories read-only into a tree.
189
+ 2. **classify** decides whether each folder is a movie, a show, or a grouping
190
+ folder holding several titles, and flags anything it is unsure about.
191
+ 3. **decisions** overlays per-title corrections from `decisions/*.json`: canonical
192
+ title, year, database ids, movie/show, editions, or skip.
193
+ 4. **planner** turns each title into concrete file moves.
194
+ 5. **executor** validates, expands into `mkdir` / `rename` / `rmdir` operations,
195
+ journals them, runs them with a progress bar, deletes the ads and
196
+ `.DS_Store` files with their bytes in the journal, then checks that every
197
+ file is where the journal says, by name, kind and size, and that every
198
+ deleted file is gone.
199
+ 6. **undo** settles every journal row against the disk, newest first.
200
+
201
+ ## Naming applied
202
+
203
+ | Case | Result |
204
+ |---|---|
205
+ | Movie | `Category/Title (Year) {imdb-tt…}/Title (Year) {imdb-tt…}.ext` |
206
+ | Movie edition | `Title (Year) {edition-Final Cut}/Title (Year) {edition-Final Cut}.ext` |
207
+ | Split movie | `Title (Year) - part1.ext` … `part8` |
208
+ | Show | `Category/Show (Year) {imdb-tt…}/Season 01/Show (Year) - S01E01 - Title.ext` |
209
+ | Multi-episode file | `Show (Year) - S02E01-E03.ext` |
210
+ | Specials | `Specials/Show (Year) - S00E01 - Title.ext` |
211
+ | Subtitle | `<video stem>.en.srt`, `.en.forced.srt`, `.en.sdh.srt` |
212
+ | Extras | `Behind The Scenes`, `Deleted Scenes`, `Featurettes`, `Interviews`, `Scenes`, `Shorts`, `Trailers`, `Other` |
213
+ | Artwork | `poster.jpg`, `background.jpg` |
214
+ | DVD rip | `Title (Year)/VIDEO_TS/…` kept intact |
215
+ | 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 |
216
+
217
+ ## Deliberate deviations from the published examples
218
+
219
+ - **`S01E01`, not `s01e01`.** Plex's examples are lowercase and the token is
220
+ matched case-insensitively. Uppercase matches what every mainstream tool
221
+ already uses. `--episode-case lower` switches it.
222
+ - **A regional label before the language code** (`Movie (2019).Brazilian.pt.srt`).
223
+ Plex documents only `name.lang.ext`, which would collide when a release ships
224
+ several variants of one language. The label form is widely supported and is the
225
+ only way to keep the distinction. It is only used from the third file of a
226
+ language on: the first two take the two code widths Plex documents, `.pt.srt`
227
+ and `.por.srt`, so `.eng.srt` beside `.en.srt` is intended.
228
+ - **A three-letter tag the tool does not know is kept as the language code.**
229
+ Release tools name subtitle files by ISO-639-2 code, so `xyz.srt` becomes
230
+ `Movie (2019).xyz.srt`, and lint lists every such code. A name with no
231
+ language in it, or with `und` (undetermined), is filed as English.
232
+ - **One id tag per title.** Plex documents `{imdb-…}`, `{tmdb-…}` and `{tvdb-…}`
233
+ as alternatives, so exactly one is emitted, preferring IMDb.
234
+
235
+ ## Tests
236
+
237
+ ```bash
238
+ python3 -m unittest discover -s tests -v
239
+ ```
240
+
241
+ Covers the naming patterns, plan validation, cycle breaking, the apply/undo
242
+ round trip byte for byte, and an explicit assertion that no file content ever
243
+ disappears from the volume. The recovery tests cut `apply` and `undo` off at
244
+ every single step, before and after the step touches the disk, and check that
245
+ the tree comes back exactly, folder metadata included, also when the tree has
246
+ been swapped for a copy with new inode numbers in between, which is what a
247
+ remount does. The safety tests put somebody else's files in the way, take the
248
+ library away, lose a reply from the share and undo runs out of order, and check
249
+ that no foreign file is touched and that every row that could not be undone is
250
+ still there for the next try. The share tests put the library on the inode
251
+ numbers an SMB share was seen to hand out on macOS: numbers above 2^63 - 1,
252
+ which are more than an SQLite integer holds, and numbers worked out from the
253
+ path, which change with every rename. On the second kind the round trip,
254
+ recovery and safety tests run a second time, apart from two safety tests whose
255
+ promise rests on an inode that holds: there each is replaced by a test of what
256
+ is left of it. The look-alike tests do the same with swaps and chains of files
257
+ of one size, folders and empty files, cut off at every step, and undo an older
258
+ run with `--force` while a later run on its paths still stands. The lookup tests
259
+ make single lookups fail where a decision rests on them, and remove folders the
260
+ way macOS does, also on a FAT volume. The delete tests check that only ads and
261
+ real `.DS_Store` files go and nothing that changed since the listing, and that
262
+ `undo` writes each one back byte for byte with its time and mode: after a lost
263
+ reply, after a cut on either side of the delete, after a remount, and never
264
+ over a file that has taken its place.
265
+
266
+ CI runs them on macOS, on Python 3.12 to 3.15, for every pull request and every
267
+ push to main.
268
+
269
+ ## Pruning shells left behind by a rename
270
+
271
+ ```bash
272
+ python3 -m plexrenamer prune --dry-run # list what would go
273
+ python3 -m plexrenamer prune # remove them, journalled
274
+ ```
275
+
276
+ `prune` removes directories that hold no media anywhere below them, but only
277
+ those this tool actually emptied, established from the journal rather than
278
+ guessed. A folder that was already empty before the tool ever ran is reported
279
+ and left alone. Any folder metadata inside a pruned directory is moved to the
280
+ parent rather than deleted, and `undo` recreates the directory and puts that
281
+ file back. A directory that still holds anything else, `._` files included, is
282
+ kept and reported.
283
+
284
+ ## Releases
285
+
286
+ A release is a tag `vX.Y.Z` on main that matches `__version__` in
287
+ `plexrenamer/__init__.py`. Pushing it runs `release.yml`: the tests and the
288
+ package build first, then the GitHub release with the wheel, the sdist and
289
+ their `SHA256SUMS`, then the upload of the same files to PyPI through Trusted
290
+ Publishing. The release refuses a tag that is not on main, or that moved after
291
+ the run started.
@@ -0,0 +1,22 @@
1
+ plexrenamer/__init__.py,sha256=Tavnl7aqZ3SA0WyoyN8pJ7fs70jviEJ_-X2jTSVHMug,627
2
+ plexrenamer/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ plexrenamer/classify.py,sha256=R2_-Z3YzEVqjphTznQDZER5UIre-9_fOlovfECgPOss,16703
4
+ plexrenamer/cli.py,sha256=W7EjmsJIEuNpf_ViiBokD5YiADBVUj639KNJsCFm-wc,21283
5
+ plexrenamer/db.py,sha256=ipC_5ronni0KEShpaI8eGbGRLJGxdkO3HksQsDjU_9E,8285
6
+ plexrenamer/decisions.py,sha256=9RNujKZ_t_GhG_2HHCZDZTCiNAPT1CMX_KeScq0TSOo,3741
7
+ plexrenamer/executor.py,sha256=CFS0YwKCikWCcycceTLFglVyG9P41keb3LYAQSJtxYk,18689
8
+ plexrenamer/junk.py,sha256=Z8_av5v_3z_fbi71yCZZjKV1v0jFgDISrj2nbT2E-gk,7761
9
+ plexrenamer/lint.py,sha256=jYFy0fjY7z8kdmjWJnmf2bHLPtYdD22ru7K4S-C0ExI,5295
10
+ plexrenamer/naming.py,sha256=4rtQdrzcvlDR57BstViXpgeJrvmRDnNrrbf8KvA3iVk,4754
11
+ plexrenamer/ops.py,sha256=gKbD9EwaNORCXhw46gt0sZry9RqAALWDAzPnBMBjNkU,6254
12
+ plexrenamer/planner.py,sha256=b9EfS7qTVyNfox7vDHCUbwzonxTeVaruLd-Z6cUEpLg,26372
13
+ plexrenamer/progress.py,sha256=D2Eg1PgdWsG2pOC2DARdoyXowq4J_dCf32i1zHmjXCg,1921
14
+ plexrenamer/scan.py,sha256=8JgcCNzlXFaCnmNqovtHFFpScN05kBzfLLglnI6CYAc,3577
15
+ plexrenamer/sidecars.py,sha256=6fK0DjDwqqwb-RKdsr53Xe1IaDG0WSCDq81pr5N3a0Y,5411
16
+ plexrenamer/undo.py,sha256=sTd3cl5RAqPoCoFrF6fj68y90t0CGQzU8YriZzfixdM,17563
17
+ plexrenamer/util.py,sha256=TdffpcCa2w0iAdxDswf52gU6YVbyDdgIXg_7EalHkus,19548
18
+ plex_renamer-1.0.0.dist-info/METADATA,sha256=7uW1-YMyEcHAf1HEj5f_EYzp23uCAHwMSfnelQgPmkc,17685
19
+ plex_renamer-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
20
+ plex_renamer-1.0.0.dist-info/entry_points.txt,sha256=SHt7FdYjxVqkrKa53fWl8WfBvQLi6PiCRnMc7GrWilc,53
21
+ plex_renamer-1.0.0.dist-info/licenses/LICENSE,sha256=RSYs2DrUUAf2WxDQD9zD2zI_kuo-uSuhwr8Wo4tZPgs,1082
22
+ plex_renamer-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plex-renamer = plexrenamer:console
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Agustin Gonzalez Nicolini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ """Plex-standard renamer for a local media volume.
2
+
3
+ Every mutation goes through a SQLite journal so that any run can be replayed
4
+ in reverse. Nothing in the library is ever deleted. The only things removed
5
+ there are empty directories: those a plan clears away, which undo recreates,
6
+ and those a run created, once they are empty again. The speed test removes
7
+ only the scratch files it wrote into a folder of its own.
8
+ """
9
+
10
+ __version__ = "1.0.0"
11
+
12
+
13
+ def console():
14
+ """The installed plex-renamer command: the commands it prints start with its own name."""
15
+ from . import cli
16
+ cli.PROG = "plex-renamer"
17
+ return cli.main()
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())