plexscripts is a collection of Plex library administration tools consolidated around the plexadm CLI. The tool reads Plex connection details from ~/.plexconfig.ini, connects with plexapi, and performs listing, tagging, collection maintenance, studio updates, writer updates, and batch processing for one Plex library.
Most commands mutate Plex immediately. There is no global dry-run mode yet.
plexadm stash reconcile scans Stash (including phash generation), then runs Stash Clean before
matching Plex items to Stash scenes by file path on a full-library reconcile. Scanning first lets
Stash update scene paths when files have moved inside the configured library; Clean then removes
database records for files that are no longer present. The underlying media files are not deleted.
Stash Clean is skipped for partial Plex scopes selected with --limit, --path, or
--added-in-last-days, because Clean affects the entire Stash library. --skip-scan skips only
the scan; Clean still runs on a full-library reconcile. Stash storage must be available for the
operations that run.
For each unambiguous path match, reconcile stores the configured Plex server's ratingKey in
the Stash scene custom field plex_rating_key as a decimal string. The key is a lookup hint;
path matching remains authoritative. A later reconcile replaces a changed key, while a scene
without a current Plex path match keeps its existing value. There is no global stale-key cleanup.
To find a scene from Plex, use Stash's custom-field filter and pass the key as a string:
query ScenesByPlexRatingKey($key: Any!, $page: Int!) {
findScenes(
scene_filter: {
custom_fields: [{field: "plex_rating_key", value: [$key], modifier: EQUALS}]
}
filter: {page: $page, per_page: 200}
) {
count
scenes { id custom_fields }
}
}Page through all results because a key can identify more than one Stash scene. The key belongs to one Plex server and may become stale if Plex removes an item while its file remains in Stash; resolve the key on the configured server and verify the path when correctness matters.
Keep the Plex library unchanged until reconcile completes. Reconcile checks the library item
count and unique rating keys, but the APIs do not provide a transactional Plex snapshot. Partial
scopes (--limit, --path, or --added-in-last-days) read the full Plex listing once to detect
shared paths, adding one library-wide read. Items without usable rating keys are skipped, and
their paths block updates to other items. If more than one Plex item can own a matched Stash
scene, all affected items are skipped. A Plex item with no descriptive metadata still updates
the key only. When several Stash scenes match one Plex item, reconcile merges them only if their
non-plexadm custom fields can be combined without conflicting values; on conflict it leaves the
scenes separate and writes the key to each scene.
scripts/mass_process.sh runs a full reconcile after its Plex tagging steps, then runs
plexadm stash backfill-tags against the refreshed Stash tags before updating review
collections. Stash storage must be available whenever the full mass-processing script runs. If
either Stash command fails, the script stops before subsequent backfill or review updates.
The backfill review JSON and markdown report are written under PLEXADM_REFERENCE_DIR. In a
checkout, scripts/plexadm-env.sh points it at the checkout's reference/; installed scripts
fall back to /usr/local/share/plexadm/reference, matching the other helper scripts.
Generated backfill reports and review data are ignored by Git and remain local to that reference
directory.
Create ~/.plexconfig.ini with a [default] section:
[default]
plexHost = 192.168.1.10
plexPort = 32400
plexToken = your-token
plexSectionName = Your Library Name
plexSection = optional-section-id
[logging]
sink = file
[logging.file]
path = ~/.plexadm/audit.jsonl
max_bytes = 10485760
backup_count = 10You can use another config file with --config PATH on Plex-backed subcommands.
Every successful, non-dry-run Plex mutation is appended as a structured audit event. Exactly one audit sink is active. The default file sink uses size-based rotation and works without adding a [logging] section to an existing config. Available sinks are file, syslog, journal, and opensearch:
[logging]
sink = opensearch
[logging.syslog]
address = /dev/log
facility = user
[logging.journal]
identifier = plexadm
[logging.opensearch]
url = https://opensearch.example.com:9200
index = plexadm-audit
username = plexadm
password = your-password
verify_tls = trueFor remote syslog, set address = host:port; Docker containers generally do not have /dev/log. The journal sink is primarily for bare-metal runs and may need the host's /run/systemd/journal mounted in a container. It also requires the optional dependency pip install systemd-python, which is intentionally not installed by default. Journal, syslog, and OpenSearch retention are managed by those systems; only the file sink handles its own rotation and retention.
Every audit event carries a level (INFO, WARNING, or ERROR), which is mapped to the matching native severity on the syslog and journal sinks (e.g. an ERROR event reaches syslog at the err priority, not info). Plex mutations are logged at INFO. In addition to mutations, plexadm logs two non-mutation event types to the same sink: any uncaught exception during a command is logged at ERROR (action = "error"), and a Ctrl-C interrupt is logged at WARNING (action = "interrupted") — both so a crashed or aborted run leaves a trace instead of only a stderr message.
Distinct from audit logging, which only records what plexadm itself did: plexadm inventory snapshot records what the library's collection membership actually looks like right now, regardless of what changed it. plexadm inventory diff compares two snapshots and reports every video whose collections changed, cross-checking the audit trail (when it's on the opensearch sink) for a matching event - a change with none is flagged UNATTRIBUTED, meaning something other than plexadm changed it (a stray Plex Web edit, an agent, etc.).
Requires its own [inventory] section, independent of the [logging] sink choice:
[inventory]
url = https://opensearch.example.com:9200
index = plexadm-inventory
username = plexadm
password = your-password
verify_tls = trueplexadm inventory snapshot
plexadm inventory diffscripts/mass_process.sh records an inventory snapshot at the end of each run, after its
Plex and Stash synchronization steps.
Install local development dependencies into .venv/:
make installRun the CLI from the repository:
./bin/plexadm --helpInstall the command, shell helpers, and reference data under /usr/local:
sudo make install-systemThat installs:
/usr/local/bin/plexadm/usr/local/lib/plexadm/.venv(isolated runtime dependencies)/usr/local/bin/plexadm-scripts/*.sh/usr/local/share/plexadm/reference- bash, zsh, and fish completion definitions in their system completion directories
plexadm supports command, subcommand, option, collection, studio, and writer
name completion through argcomplete. A system install registers completion for
bash, zsh, and fish automatically. For a development checkout, add the command
for your shell to its startup file:
# zsh (~/.zshrc) or bash (~/.bashrc)
eval "$(register-python-argcomplete plexadm)"# fish (~/.config/fish/config.fish)
register-python-argcomplete --shell fish plexadm | sourceCollection, studio, and writer suggestions come from a local cache so pressing Tab never waits for Plex or fails when Plex is unavailable. Seed or refresh it after library changes with:
plexadm completion refresh-cacheThis writes ~/.plexadm/completion-cache.json. Run the refresh command
periodically (manually or with your system scheduler) if you want newly added
names to appear in completion results.
Build and run the default mass process:
docker compose up --buildThe compose file mounts:
~/.plexconfig.ini:/root/.plexconfig.ini:ro/mnt/myzmirror/plexscripts/audit:/root/.plexadm:rw./reference:/app/reference:rw
The audit mount preserves the default file sink's JSONL files when a container is removed. Adjust the host path to suit the deployment.
Override the default command with a shell:
docker compose run --rm plexadm bashRun one command directly:
docker compose run --rm plexadm plexadm list collections
docker compose run --rm plexadm bash scripts/set_tags_based_on_title.shrestart is set to "no".
Normal validation flow:
make lintfix && make lint && make testTargets:
make install: create.venv/and installrequirements-dev.txtmake lintfix: runruff formatandruff check --fixmake lint: run ruff, mypy, shell syntax checks, shellcheck if installed, and hadolint. If hadolint is not installed, it runs via Docker.make test: run pytest with coverage and writecoverage.xmlfor Codecovmake install-system: install command and scripts to/usr/local
The GitHub Actions Docker workflow builds on pull requests and pushes to GHCR on main, version tags, and manual dispatch.
Top-level command groups:
plexadm list ...
plexadm collection ...
plexadm studio ...
plexadm writers ...
plexadm smart-collections ...
plexadm tools ...
plexadm top ...All Plex-backed subcommands support:
--config PATHList all videos:
plexadm list videosFilter videos by title text, prefix, or regex:
plexadm list videos --title "pattern"
plexadm list videos --startswith "Alice"
plexadm list videos --regex "Scene #[0-9]+"Use Plex search by title:
plexadm list videos --search-title "pattern"List videos by collection, studio, writer, or missing studio:
plexadm list videos --collection "00C: Unrated"
plexadm list videos --studio "Studio Name"
plexadm list videos --writer "Writer Name"
plexadm list videos --no-studioFind titles that do not contain the expected - separator:
plexadm list videos --no-title-spacesForce reload while listing all videos:
plexadm list videos --reloadList collections with item counts:
plexadm list collectionsFilter collection titles:
plexadm list collections "01: Category:"
plexadm list collections "03: Star:"List studios with counts:
plexadm list studiosFilter studios:
plexadm list studios "Tushy"List writers with counts across the library:
plexadm list writersList writers appearing in a collection:
plexadm list writers --collection "01: Category: Solo"List writers appearing under a studio:
plexadm list studio-writers "Studio Name"Special list kinds:
uncategorizeduncollectedmergedpotential-indiemulti-f-without-categoryno-compositionno-hairduplicates
Examples:
plexadm list special uncategorized
plexadm list special no-hair
plexadm list special uncollected
plexadm list special duplicatesduplicates prints the title of every video Plex flags duplicate=true (multiple file
versions attached to one item), followed by each file's path with the /data/ prefix
replaced by --base-dir (default: /mnt/myzmirror/plexdata/). For sizes, durations,
resolutions, and a delete recommendation instead of just paths, use
plexadm tools dupes-report.
List videos whose filename does not match their Plex title:
plexadm list renames
plexadm list renames "TUSHY"Override the base directory prefix stripped from file paths (default: /data/NSFW Scenes/):
plexadm list renames --base-dir "/other/path/"Message, Post, PPV, and titles containing ? are excluded automatically.
This command only reports; it never mutates anything. To generate a mv script from the same
underlying data, use plexadm tools rename-gen-script instead.
These commands add or remove collection membership immediately.
Scan every title locally and add matching videos to a collection:
plexadm collection add-title "01: Category: Anal" "Anal"Match title prefix instead of substring:
plexadm collection add-title "02: Independent Content" "Alice" --startswithSkip scene-style titles:
plexadm collection add-title "02: Independent Content" "Alice" --skip-scenesUse Plex search filters to add title matches not already in the collection:
plexadm collection add-search "01: Category: Anal" "Anal"This is what scripts/set_tags_based_on_title.sh uses.
Scan titles for a writer pattern, confirm the Plex writer exactly matches, then add to a collection:
plexadm collection add-writer "01: Composition: Solo" "Writer Name"Add all videos matching any writer in a file:
plexadm collection add-writers "01: Composition: Solo" reference/writers_solo.txtWriter files are newline-delimited. Pass --single-writer-only to skip videos with more than one
credited writer - use this for Solo, so a listed performer co-starring in someone else's scene
doesn't get that scene tagged Solo too:
plexadm collection add-writers --single-writer-only "01: Composition: Solo" reference/writers_solo.txtCopy membership from one collection to another:
plexadm collection copy "01: Category: Deepthroat" "01: Category: Blowjob"Only videos not already in the target collection are added.
Add all videos from a studio to a collection:
plexadm collection copy-studio "Tushy" "01: Category: Anal"Remove matching videos from a collection:
plexadm collection remove-title "01: Category: Example" "pattern"Add videos shorter than 90 seconds by default:
plexadm collection add-short "01: Category: Short Videos"Set a different duration threshold in milliseconds:
plexadm collection add-short "01: Category: Short Videos" --max-duration-ms 120000Add videos where media height is greater than width:
plexadm collection add-vertical "01: Category: Vertical Video"Add unrated videos and remove rated videos:
plexadm collection sync-unrated "00C: Unrated"The default collection is 00C: Unrated:
plexadm collection sync-unratedAdd videos with empty studio and remove videos that now have a studio:
plexadm collection sync-no-studio "00A: NO STUDIO"The default collection is 00A: NO STUDIO:
plexadm collection sync-no-studioAdd videos whose filename matches *- PPV *. Plex has no native filter for filename/file path, so
the match happens in Python against each media part's filename rather than as a Plex search
filter. Add-only: a filename no longer matching the pattern (e.g. after a manual rename) doesn't
mean the video stopped being valid PPV content, so existing members are never removed:
plexadm collection add-ppv "01: Category: PPV"The default collection is 01: Category: PPV:
plexadm collection add-ppvLock the title and sort title fields for every item in a collection to their current values, so agent refresh/matching can't silently overwrite a manually-picked title (e.g. merged-duplicate items whose title was chosen by hand from among several release names):
plexadm collection lock-titles "00A: DUPES"Applies even to 99: LOCKED items: it only locks each field to whatever value it already has,
so it preserves existing metadata rather than altering it, unlike the collection-membership
changes that guard exists to block.
Renames existing 01: Category: collections into the emerging Activity/Composition/Cumshot/
Prop/Theme/Attributes taxonomy, per the hand-classified table in
plexadm.stash_backfill_tags._EXISTING_CATEGORY_RENAMES (the same table
plexadm stash unmapped-tags's rename-suggestions section is built from). Only renames
collections that actually exist; anything left unclassified (format tags, a likely studio name,
etc.) is untouched.
Composition collections (Solo, MMF, FFM, Lesbian, Orgy, ...) are skipped by default -
plexadm stash backfill-tags matches these against Stash tags by exact name, so renaming them
here without also renaming the matching Stash tags would silently break that matching:
plexadm collection rename-categories --dry-run
plexadm collection rename-categoriesPass --include-composition once the corresponding Stash tags have a matching rename in place.
plexadm stash rename-tags renames the Stash side (Category: Solo → Composition: Solo, etc.)
via GraphQL - run it, then update GROUP_SINGLE_FEMALE/GROUP_MULTI_FEMALE_HEADCOUNT/
GROUP_MULTI_FEMALE_ACTIVITY in stash_backfill_tags.py to the new names in the same change,
before passing --include-composition here:
plexadm stash rename-tags --dry-run
plexadm stash rename-tagsStatus: complete. All 01: Category: collections have been migrated except the 4 left
deliberately unclassified (Beautiful Agony, Non-Sexual, Short Videos, Vertical Video) and PPV.
The commands above remain as the documented procedure in case a newly-added collection ever
needs the same treatment.
This is a one-time migration, not part of mass_process.sh. scripts/rename_categories.sh
wraps it with logging (defaults to a dry-run preview; pass --apply to actually rename). Every
other script that references a renamed collection by its old name
(set_tags_based_on_title.sh, copy_collections.sh, set_tags_based_on_writers.sh,
mass_process.sh) was updated to the new names in the same change that added this command - run
the rename before the next mass_process.sh run, or those will fail to find their target
collections.
Add every sexual, non-female-only video with no Cumshot collection (Facial, Bukkake, Cum In
Mouth, ...) to a review collection, and remove anything that no longer matches. "Non-female-only"
excludes Solo/Lesbian/FF Only/Female Only - no male performer present, so no cumshot to have -
and Non-Sexual content is excluded too. Checks both the pre- and post-rename-categories name
for every Cumshot collection, so it works whether or not that migration has run yet:
plexadm collection sync-cumshot-absent "00D: Review: Cumshot Absent"The default collection is 00D: Review: Cumshot Absent:
plexadm collection sync-cumshot-absentThese commands update Plex studio fields immediately.
Set a studio on title matches that do not already have a studio:
plexadm studio set-title "Studio Name" "pattern"Require the pattern to match an exact Plex writer too:
plexadm studio set-title "Independent Content" "Writer Name" --require-writerSkip scene-style titles:
plexadm studio set-title "Independent Content" "Writer Name" --skip-scenesShortcut for independent content. It requires exact writer match and skips scenes:
plexadm studio set-independent "Writer Name"Use a newline-delimited writer file:
plexadm studio bulk-independent reference/writers_indie.txtRename studio values across matching videos:
plexadm studio rename "Old Studio" "New Studio"Parse writer names from the title prefix before the first - and add missing Plex writers:
plexadm writers set-from-titlesExample title format:
Alice, Bob - Example Title
Set missing writers from titles, then create missing smart collections for studios and writers:
plexadm writers set-and-syncThis is the first step in scripts/mass_process.sh.
Normalize case-only studio variants and create missing smart collections for all discovered studios and writers:
plexadm smart-collections syncStudio smart collections are named:
02: Studio: Studio Name
Writer smart collections are named:
03: Star: Writer Name
Independent Content is named:
02: Independent Content
For a studio whose spelling differs only by capitalization, an existing generated smart
collection's studio filter is canonical and matching video metadata is corrected to that
spelling. If no collection exists yet, a unique most-common spelling is used; tied spellings are
reported and skipped rather than choosing an arbitrary case style. If stale collections already
provide conflicting case-only filters, a unique library majority resolves the conflict; a tie is
again skipped. These studio edits use the same dry-run, audit logging, and 99: LOCKED
protections as other plexadm metadata changes.
Rename collections using a Python regular expression replacement:
plexadm smart-collections rename "^Old Prefix: " "New Prefix: "Clone an existing smart collection under a new name, AND-combining its filters with an extra one:
plexadm smart-collections clone "00D: Review: No Hair Color" "00D: Review: No Hair Color (Indie)" \
--add-filter '{"studio": "Independent"}'SOURCE must already be a smart collection; TARGET must not already exist.
Show top category collections:
plexadm top categories
plexadm top categories --limit 25Show top studios:
plexadm top studiosShow top writers or scenes missing studios:
plexadm top writers-without-studios
plexadm top scenes-without-studiosShow unrated writer or scene counts:
plexadm top unrated-writers
plexadm top unrated-scenesOverride the collection used by top reports where applicable:
plexadm top unrated-writers --collection "00C: Unrated"plexadm fixnames renames a single downloaded video in place to the library's
standard naming scheme. Choose the source explicitly:
plexadm fixnames dlscenes "Scene 3 From Example Title - 1080p.mp4" --prefix "WRITER NAME"
plexadm fixnames wowgirls "WriterOne_ExampleTitle_1920x1080_30fps.mp4"
plexadm fixnames ultralove "example-title_writer-one_1920x1080.mp4"Or let auto distinguish the three source formats from their scene, resolution,
and frame-rate markers:
plexadm fixnames auto "WriterOne_ExampleTitle_1920x1080_30fps.mp4"auto leaves a filename that already resembles Writer - Title.mp4 unchanged
and exits non-zero when it cannot recognize the source format. Its optional
--prefix is used only when the detected format is dlscenes.
Find which Plex item references a file path:
plexadm tools find-missing-file "/path/to/file.mp4"Generate mv commands for videos whose filename doesn't match their Plex title (the script-
generation counterpart to plexadm list renames, which only reports):
plexadm tools rename-gen-script > rename.sh
plexadm tools rename-gen-script "TUSHY" --base-dir "/other/path/"Videos with multiple file locations are skipped, since it's ambiguous which one to rename;
plexadm list renames flags those with a WARNING instead.
Generate a markdown report of Plex duplicate=true videos (multiple file versions attached to
one item), with a delete recommendation per group:
plexadm tools dupes-report
plexadm tools dupes-report --output reference/dupes_report.md --base-dir "/other/mount/plexdata/"For each duplicate-flagged video, the report lists every file's full path (/data/ replaced by
--base-dir, default /mnt/myzmirror/plexdata/), duration, size, and resolution, plus whether
the item's title and sort title are locked fields. Recommendations are a starting point for
manual review, never an automatic deletion:
- durations don't match (more than 1 second apart) -> needs manual review
- durations match and a PPV file is the highest resolution -> delete the non-PPV file(s)
- durations match and no file contains PPV -> delete the lowest-resolution/size file(s)
- durations match and a PPV file exists but isn't the highest resolution -> needs manual review
Print OFDL name mappings from JSON:
plexadm tools ofdl-gen-names --map-file reference/indie_usernames_to_map.jsonRun rsync:
plexadm tools ofdl-rsync SOURCE DESTINATIONRemove _23fps, _24fps, _25fps, _30fps, _50fps, or _60fps style title suffixes:
plexadm tools remove-fps-title "Video_60fps.mp4"Upload local *.mp4 files to a remote host:
plexadm tools upload-vids
plexadm tools upload-vids --remote-host truenas --upload-path "/mnt/myzmirror/plexdata/NSFW Scenes"The scripts/ directory contains batch wrappers around plexadm.
Important scripts:
scripts/mass_process.sh: full batch processscripts/set_tags_based_on_title.sh: title-search category taggingscripts/set_tags_based_on_writers.sh: writer-file tagging and independent content updatesscripts/copy_collections.sh: collection and studio propagation rulesscripts/set_unrated.sh: unrated collection sync with log outputscripts/set_ppv.sh: PPV filename-pattern collection sync with log outputscripts/rename_categories.sh: one-time taxonomy rename migration - NOT part of mass_process.shscripts/top_*.sh: convenience reports
Run the full batch:
bash scripts/mass_process.shreference/ is for local runtime data, writer lists, logs, and notes. Files such as reference/*.txt, reference/*.log, reference/*.md, and reference/*.json are ignored by git.
reference/legacy-python/ contains the archived pre-refactor Python scripts for comparison.
Rather than embedding credentials in your docker-compose.yml, you can store them in a .env file and bind-mount it into the container:
cp .env.example .env
# edit .env with your valuesservices:
app:
image: ghcr.io/jasmeralia/plexscripts:latest
volumes:
- /path/to/your/.env:/app/.env:roThe app loads /app/.env automatically on startup. Any value in .env can still be overridden by an explicit environment: entry in your Compose file.