Skip to content

About

Collection of scripts that I use to help administer my primary Plex library.

Topics

Resources

Stars

4 stars

Watchers

1 watching

Forks

Repository files navigation

plexscripts

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.

Stash Reconcile

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.

Configuration

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 = 10

You 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 = true

For 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.

Inventory (plexadm inventory)

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 = true
plexadm inventory snapshot
plexadm inventory diff

scripts/mass_process.sh records an inventory snapshot at the end of each run, after its Plex and Stash synchronization steps.

Install

Install local development dependencies into .venv/:

make install

Run the CLI from the repository:

./bin/plexadm --help

Install the command, shell helpers, and reference data under /usr/local:

sudo make install-system

That 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

Shell Completion

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 | source

Collection, 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-cache

This 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.

Docker

Build and run the default mass process:

docker compose up --build

The 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 bash

Run one command directly:

docker compose run --rm plexadm plexadm list collections
docker compose run --rm plexadm bash scripts/set_tags_based_on_title.sh

restart is set to "no".

Development

Normal validation flow:

make lintfix && make lint && make test

Targets:

  • make install: create .venv/ and install requirements-dev.txt
  • make lintfix: run ruff format and ruff check --fix
  • make 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 write coverage.xml for Codecov
  • make 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.

Command Overview

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 PATH

Listing Commands

Videos

List all videos:

plexadm list videos

Filter 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-studio

Find titles that do not contain the expected - separator:

plexadm list videos --no-title-spaces

Force reload while listing all videos:

plexadm list videos --reload

Collections

List collections with item counts:

plexadm list collections

Filter collection titles:

plexadm list collections "01: Category:"
plexadm list collections "03: Star:"

Studios

List studios with counts:

plexadm list studios

Filter studios:

plexadm list studios "Tushy"

Writers

List writers with counts across the library:

plexadm list writers

List 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 Lists

Special list kinds:

  • uncategorized
  • uncollected
  • merged
  • potential-indie
  • multi-f-without-category
  • no-composition
  • no-hair
  • duplicates

Examples:

plexadm list special uncategorized
plexadm list special no-hair
plexadm list special uncollected
plexadm list special duplicates

duplicates 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.

Renames

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.

Collection Commands

These commands add or remove collection membership immediately.

Add By Title Scan

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" --startswith

Skip scene-style titles:

plexadm collection add-title "02: Independent Content" "Alice" --skip-scenes

Add By Plex Search

Use 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.

Add By Writer

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.txt

Writer 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.txt

Copy Between Collections

Copy 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.

Copy Studio To Collection

Add all videos from a studio to a collection:

plexadm collection copy-studio "Tushy" "01: Category: Anal"

Remove By Title

Remove matching videos from a collection:

plexadm collection remove-title "01: Category: Example" "pattern"

Add Short Videos

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 120000

Add Vertical Videos

Add videos where media height is greater than width:

plexadm collection add-vertical "01: Category: Vertical Video"

Sync Unrated Collection

Add unrated videos and remove rated videos:

plexadm collection sync-unrated "00C: Unrated"

The default collection is 00C: Unrated:

plexadm collection sync-unrated

Sync No-Studio Collection

Add 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-studio

Add PPV Collection

Add 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-ppv

Lock Titles

Lock 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.

Rename Categories (one-time taxonomy migration)

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-categories

Pass --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-tags

Status: 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.

Sync Cumshot Absent Review Collection

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-absent

Studio Commands

These commands update Plex studio fields immediately.

Set Studio By Title Pattern

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-writer

Skip scene-style titles:

plexadm studio set-title "Independent Content" "Writer Name" --skip-scenes

Set Independent Studio

Shortcut for independent content. It requires exact writer match and skips scenes:

plexadm studio set-independent "Writer Name"

Bulk Set Independent

Use a newline-delimited writer file:

plexadm studio bulk-independent reference/writers_indie.txt

Rename Studio

Rename studio values across matching videos:

plexadm studio rename "Old Studio" "New Studio"

Writer Commands

Set Writers From Titles

Parse writer names from the title prefix before the first - and add missing Plex writers:

plexadm writers set-from-titles

Example title format:

Alice, Bob - Example Title

Set Writers And Sync Smart Collections

Set missing writers from titles, then create missing smart collections for studios and writers:

plexadm writers set-and-sync

This is the first step in scripts/mass_process.sh.

Smart Collection Commands

Sync Smart Collections

Normalize case-only studio variants and create missing smart collections for all discovered studios and writers:

plexadm smart-collections sync

Studio 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

Rename collections using a Python regular expression replacement:

plexadm smart-collections rename "^Old Prefix: " "New Prefix: "

Clone A Smart Collection

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.

Top Reports

Show top category collections:

plexadm top categories
plexadm top categories --limit 25

Show top studios:

plexadm top studios

Show top writers or scenes missing studios:

plexadm top writers-without-studios
plexadm top scenes-without-studios

Show unrated writer or scene counts:

plexadm top unrated-writers
plexadm top unrated-scenes

Override the collection used by top reports where applicable:

plexadm top unrated-writers --collection "00C: Unrated"

Fix Downloaded Filenames

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.

Tools

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.json

Run rsync:

plexadm tools ofdl-rsync SOURCE DESTINATION

Remove _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"

Shell Scripts

The scripts/ directory contains batch wrappers around plexadm.

Important scripts:

  • scripts/mass_process.sh: full batch process
  • scripts/set_tags_based_on_title.sh: title-search category tagging
  • scripts/set_tags_based_on_writers.sh: writer-file tagging and independent content updates
  • scripts/copy_collections.sh: collection and studio propagation rules
  • scripts/set_unrated.sh: unrated collection sync with log output
  • scripts/set_ppv.sh: PPV filename-pattern collection sync with log output
  • scripts/rename_categories.sh: one-time taxonomy rename migration - NOT part of mass_process.sh
  • scripts/top_*.sh: convenience reports

Run the full batch:

bash scripts/mass_process.sh

Reference Data

reference/ 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.

Naming Scheme

See docs/naming-scheme.md.

Using a .env file

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 values
services:
  app:
    image: ghcr.io/jasmeralia/plexscripts:latest
    volumes:
      - /path/to/your/.env:/app/.env:ro

The app loads /app/.env automatically on startup. Any value in .env can still be overridden by an explicit environment: entry in your Compose file.

About

Collection of scripts that I use to help administer my primary Plex library.

Topics

Resources

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages