A sketch for adding a "Rename Library" feature that brings on-disk filenames in line with the de facto Plex / Jellyfin / Radarr standard, using each movie's already-persisted TMDB match as the source of truth.
Per-movie folder + ID-embedded filename, matching Radarr's default:
Movies/
Movie Title (YYYY) {tmdb-12345}/
Movie Title (YYYY) {tmdb-12345}.mkv
Movie Title (YYYY) {tmdb-12345}.en.srt
Subs/
Movie Title (YYYY) {tmdb-12345}.de.srt
Movie Title (YYYY) {tmdb-12345}.ja.srt
The TMDB ID inside {tmdb-N} is the killer move — both Plex and Jellyfin
parse it and bypass their title-based scrapers entirely. Sidesteps every
year-disagreement / fuzzy-title issue we spent the day on.
Applied to the composed "{Title} ({Year}) {tmdb-N}" string before it
hits disk:
| Character | Action | Reason |
|---|---|---|
: |
replace with - |
banned on Windows / SMB |
/, \ |
replace with - |
path separators |
?, *, <, >, |, " |
strip | filesystem-illegal on Windows |
Trailing . or |
strip | invalid on Windows |
| Multiple spaces | collapse to one | cosmetic |
| Unicode / diacritics | preserve | UTF-8 works on APFS/NTFS/SMB-modern; never transliterate é→e |
| Length cap | 200 chars total | filesystem 255-byte limit |
&, !, ', ,, parens |
preserve | legal everywhere, common in titles |
Movies/SomeFilm.2019.1080p.mkv
Create Movies/Movie Title (2019) {tmdb-N}/, move the video into it,
rename in place.
Movies/SomeFolder/SomeFilm.2019.1080p.mkv
Rename the containing folder and the video. End state matches case 1.
Rename the SRT so its base matches the video and any detected language tag survives in ISO 639-1 form:
| Before | After |
|---|---|
SomeFilm.2019.srt |
Movie Title (2019) {tmdb-N}.srt |
SomeFilm.2019.eng.srt |
Movie Title (2019) {tmdb-N}.en.srt |
SomeFilm.2019.fra.forced.srt |
Movie Title (2019) {tmdb-N}.fr.forced.srt |
Plex / Jellyfin auto-detect .en.srt etc. from the base name; preserve
that information when it's already there.
Canonical folder name: Subs. There's no formal RFC, but Subs/
is the dominant convention — release groups, scene tools, Radarr,
FileBot, TinyMediaManager all produce it, and both Plex and Jellyfin
scan it without configuration. Subtitles works too but is rarer.
- Rename whatever the folder is called (
SUB,Subtitles,subs,s) to canonicalSubs. - Inside, rename each
.srtto{video_base}.{lang}.srtwhen the language is detectable from the filename or path; otherwise keep the original stem (still inside the canonicalSubs/folder). - Language detection: scan for ISO 639-1/2/3 codes (
en,eng,es,spa,fr,fra,fre,ja,jpn, …) and normalize to the ISO 639-1 short form for Plex/Jellyfin.
- Multiple videos in one folder (extras / specials / sample) — flag, don't touch.
- Multi-disc films (
Movie - CD1.mkv/CD2.mkv) — detect, skip. - Already-canonical files — detect via regex on the proposed pattern, skip silently.
Destructive disk operations. Mirror the existing cleanup-tool UX:
- New "Rename Library" toolbar button + dedicated window.
- Window lists every matched movie (
tmdbId != nil) in a 3-column table:- Current path (file or folder)
- Proposed path
- Per-row checkbox (default checked when proposed ≠ current)
- Header shows count + sanity line: "N rows would be renamed".
- Footer: Select All / Deselect All (consistent with the matcher), Apply (borderedProminent), Cancel.
- Apply triggers a confirmation modal with the existing destructive language ("These changes are permanent and not undoable").
- Apply runs serially with a progress bar + currently-acting path. Each row's failure is captured and surfaced inline; the loop continues.
movies.path is the primary key in SQLite. Each successful rename has
to update the row's path and filename in the same transaction the
rename runs in, or the tmdb_id link orphans.
Add to MovieStore:
func updatePath(oldPath: String, newPath: String, newFilename: String) throwsCalled from the renamer immediately after each successful FileManager.moveItem.
Sources/MovieStats/RenameModel.swift—@Observablemodel; builds the rename plan fromappModel.movies, exposesrows: [RenameRow], drives Apply serially.Sources/MovieStats/RenameView.swift— the new window UI; mirrors the MatcherView pattern.Sources/MovieStats/Services/FilenameSanitizer.swift— pure functions for composing + sanitizing the canonical filename. Testable in isolation.Sources/MovieStats/Services/SubtitleClassifier.swift— language code detection in subtitle filenames.
MovieStatsApp.swift— register the newWindow.ContentView.swift— add a "Rename Library" toolbar button next to "Match TMDB".Services/MovieStore.swift—updatePath(...)method.
- Network volumes: most movies live on a NAS. Same-volume renames
use
rename(2)(atomic, fast); cross-volume copies-then-deletes (slow, not atomic).FileManager.moveItemhandles both; surface slow ones. - Case-insensitive filesystems (default APFS):
Avatar.mkv→avatar.mkvis a no-op. Detect and skip. - Collection folders (e.g.
Marvel/Avengers (2012).mkv): the user-organized layer above the per-movie folder should be respected. Only rename the immediate movie folder + the file inside it; never flatten upward. - TMDB title vs filename's parsed title: the proposed name uses the TMDB-canonical title, not the user's parsed one. Surface in the preview so the user can compare.
- Missing
tmdb_moviesrow for a path that hastmdb_idset: skip the row, surface a warning. - Backup / undo: not in scope for v1. Phase 4 could write an
append-only log of
(oldPath, newPath, ts)we could replay in reverse.
Phase 1 — preview-only MVP
FilenameSanitizerwith unit testsRenameModelplan generator (no execution)RenameViewshowing the proposed renames; no Apply yet- Lets us eyeball what would happen across the whole library before any byte hits disk.
Phase 2 — execution
- Apply with serial execution + progress + per-row error reporting
MovieStore.updatePath- Confirmation modal
Phase 3 — subtitles
- Sidecar
.srtrenames Subs/folder canonicalizationSubtitleClassifierlanguage detection
Phase 4 — polish
- Edition tag handling (
{edition-Director's Cut}etc.) - Multi-disc detection + handling
- Move log + reverse-replay undo