Skip to main content

plexdo

A command-line interface for Plex Media Server built on plexapi. It builds and copies playlists, exports libraries to M3U or a static HTML photo gallery, synchronises watched state between users, streams media to stdout, and manages library scans.

Install

pip install plexdo

From a source checkout:

git clone https://github.com/sidusnare/plexdo.git
cd plexdo

make install installs the package, the shell completions, and the man page together:

make install                     # completion -> ~/.local/share/bash-completion/completions
make install PREFIX=/usr/local   # completion -> /usr/local/share/bash-completion/completions
make uninstall                   # removes both again

For development:

make develop                     # editable install with the dev extras
make check                       # lint, build, and validate the distribution
make help                        # all targets, and the completion path in use

Both plexdo and plexdo are installed as console scripts; they are the same program.

Getting started

plexdo write-config-example        # writes ~/.local/etc/plexdo.ini (mode 0600)
plexdo login                       # prompts, saves a token to token_path
plexdo list-libraries

write-config-example --help prints the exact template it would write.

Configuration

The configuration file lives where the platform expects it:

configuration completion cache default token_path
Linux, macOS ~/.local/etc/plexdo.ini ~/.cache/plexdo $XDG_RUNTIME_DIR/.plex.token
Windows %LOCALAPPDATA%\PlexDo\plexdo.ini %LOCALAPPDATA%\PlexDo\Cache %TEMP%\plexdo.token

cache_dir in the [plex] section moves the completion cache anywhere you like; the platform default is written into the template as a comment, and the shell completions read the same setting.

plexdo write-config-example writes to the right one for you, and its --help prints the exact template, with the notes that apply to your platform.

[plex]
url = http://localhost:32400

# Values may contain environment variables as $VAR or ${VAR}, and ~ for your
# home directory. XDG_RUNTIME_DIR is a private, user-only tmpfs on most Linux
# systems, which suits a secret -- but it is cleared at logout, so you will
# need to run `plexdo login` again after each reboot. Point token_path
# somewhere persistent if you would rather not.
token_path = $XDG_RUNTIME_DIR/.plex.token

# Optional credentials used by `plexdo login`.
# The password is stored in plaintext, so keep this file mode 0600.
# Supplying --username on the command line ignores the password below.
# username = you@example.com
# password = your-plex-password

Multiple users

A Plex server refuses an admin-issued token for a user it has shared nothing with, so acting on that user needs their own credentials. Add a section named for their user ID (from plexdo list-users):

[99]
username = bob@example.com
password = bobs-plex-password

When a command hits a 401 for a user, three sources are tried in order:

  1. the server-scoped token the admin can mint for them
  2. a token already saved in the token file
  3. a fresh login with the credentials in their [<user_id>] section

A token obtained by step 3 is written back to the token file, so the login happens once rather than on every run.

The token file

token_path holds a JSON object of username to token:

{
  "@admin": "xxxxxxxxxxxxxxxxxxxx",
  "bob@example.com": "yyyyyyyyyyyyyyyyyyyy"
}

@admin is the reserved key used when no [plex] username is set; @ cannot appear in a Plex username, so it never collides with a real one. The file is written atomically with mode 0600. A file from an earlier version holding a single bare token is read as the admin token and converted to JSON on the next write - no manual migration needed.

Environment variables are expanded in every value. A name that is not set is left as literal text and warned about, rather than failing later as a puzzling "no such file". % needs no escaping - interpolation is disabled, so a password may contain one freely.

Both the config file and the token file are checked at startup and a warning is printed if either is readable by group or other.

Commands

Every command accepts -f/--format, -v/--verbose, --debug, --dry-run, and -V/--version, and they may be given either before or after the command name - plexdo --json list-users and plexdo list-users --json are equivalent. Logging always goes to stderr, so machine-readable output on stdout is safe to pipe.

Output formats

plexdo list-libraries                  # aligned table (default)
plexdo list-libraries --json           # shorthand for -f json
plexdo -f yaml list-libraries
plexdo list-titles 3 -f csv > titles.csv
plexdo list-users -f clixml            # PowerShell Import-Clixml
format notes
table Default. Box-drawn and width-aligned, ASCII fallback on legacy consoles.
json Non-ASCII escaped, so it prints on any console.
yaml Strings are always quoted, so a title like NO or 1.10 stays a string rather than being reinterpreted as a boolean or a float.
csv Header row plus one row per record, \n line endings.
clixml PowerShell CLIXML with typed properties; pipe into Import-Clixml.

-V/--version prints the installed version, which also appears in --help.

Table width

Table output is fitted to your terminal: the widest column is truncated with an ellipsis so a row stays on one line, and --verbose reports which columns were narrowed.

plexdo list-titles "TV Shows"           # fitted to the terminal
plexdo -W list-titles "TV Shows"        # every character, however wide
plexdo list-titles "TV Shows" > out.txt # redirected output is never truncated

Redirected output keeps every character, so piping to a file or to grep is unaffected.

Throttling

Some operations query once per item: walking every season of a show library, applying watched state item by item, or reading each photo album. Past 50 items those are paced at --throttle seconds apart, 0.25 by default, so a large run does not flood the server or trip its rate limiting. Smaller runs are never paced.

plexdo --throttle 0 find-missing      # as fast as the server will answer
plexdo --throttle 1 find-missing      # gentler on a Raspberry Pi

When pacing kicks in it says so, with the expected duration:

Pacing 500 requests 0.25s apart to go easy on the server; about 2m04s,
or --throttle 0 to disable.

Anywhere a library is required you may pass either the numeric library ID or its title, so plexdo list-titles 3 and plexdo list-titles "TV Shows" are equivalent. The same applies to --library-id on search and -l on copy-watched.

Anywhere a user is required you may pass either the numeric user ID or the user's title, so plexdo list-playlists 7 and plexdo list-playlists Alice are equivalent. 0 always means the admin account. Titles match exactly first, then case-insensitively. If a value is simultaneously one user's ID and another user's title the ID wins, with a warning naming the user that was skipped; if two users share a title the command aborts and asks for the numeric ID. The identical rules apply to library IDs and titles.

Listing and searching

Command Purpose
list-libraries Library IDs, types, and titles
list-titles <library> [--album A] Titles in a library (alias: list-library)
find-missing <show> [-l L] [-A] [-s N,N] Seasons with gaps in their episode numbering
list-show <rating_key> [--m3u P] Every episode of a show, specials skipped
list-users User IDs, account types, and titles
list-playlists <user_id> A user's playlists
list-playlist <user_id> <playlist|ratingKey> [--m3u P] Items in one playlist
show-metadata <rating_key> Full metadata for one item, including its file paths
search <user_id> <query> [--media-type T] [--library-id N] Search as a given user

list-titles shows the rating key, title, release date, rating, and studio as a table. A machine-readable format carries every field the server returned for each item, and for a show library nests each show's episodes under a seasons object:

plexdo list-titles "TV Shows" -f json | jq '.[0].seasons | keys'

Nested objects such as media and their parts are expanded rather than printed as object references, so a file name is at media[].parts[].file, and the paths are repeated in a top-level files list:

plexdo list-titles Movies -f json | jq -r '.[].files[]'

Note that sourceURI is not a file name. It is the remote server URI of an item in someone else's playlist, and is null for anything on your own server.

Those fields are the ones present in the library listing; show-metadata is the route to the full picture for a single item.

Finding gaps

plexdo find-missing "Breaking Bad"          # one show, by title
plexdo find-missing 1234                    # or by ratingKey
plexdo find-missing "The Wire" -s 1,3       # only these seasons
plexdo find-missing "The Wire" -l "TV Shows"
plexdo find-missing -A                      # every show, every library
plexdo find-missing -A -l "TV Shows"        # every show in one library

Reports the episode numbers missing between the start of a season and its highest episode. A season that simply stops early is not reported, since an unaired episode cannot be told from a missing one. Season 0 is skipped by default because specials are numbered irregularly, though naming it with -s 0 includes it.

┌──────────────┬───────────┬────────┬─────────┬──────────────┬──────┬─────────┐
│ show         │ ratingKey │ season │ missing │ missingCount │ have │ highest │
├──────────────┼───────────┼────────┼─────────┼──────────────┼──────┼─────────┤
│ Breaking Bad │ 1         │ 1      │ 5       │ 1            │ 6    │ 7       │
│ The Wire     │ 2         │ 1      │ 1, 2    │ 2            │ 3    │ 5       │
└──────────────┴───────────┴────────┴─────────┴──────────────┴──────┴─────────┘

Building playlists

plexdo build-interleaved "Mixed Run" 101 202       # round-robin across shows
plexdo build-chronological "By Air Date" 101 202   # date-sorted, shows and movies
plexdo build-randomize 0 "Source" "Shuffled"       # secrets-backed shuffle

Playlists are always built fully in memory, validated, previewed, and then created in exactly one API call. --dry-run stops after the preview.

Every command that takes an existing playlist accepts its name or its ratingKey interchangeably. Every command that creates one takes -o/--overwrite: without it a name collision is refused outright, leaving both the existing playlist and the new one untouched; with it the existing playlist is removed and replaced.

build-chronological estimates missing air dates from the median interval between neighbouring episodes in the same season, and prompts only when that is impossible.

Copying and modifying

plexdo copy-playlist-to-user 0 "Mix" 7 "Mix" -o
plexdo copy-playlist-all-users 0 "Mix"          # skips the source user
plexdo append-playlist 0 "Mix" 55 56 57
plexdo remove-playlist 0 "Mix"

A user with no libraries shared to them cannot be acted on, even by the server admin - Plex scopes each token to what that user can see. Commands targeting one such user stop with an explanation; copy-playlist-all-users skips them with a one-line warning and carries on.

copy-playlist-all-users prints the item list once and then one line per user, since the list is identical for everyone:

Movie Night (3 items)
┌───────┬───────────┬───────────────────┐
│ index │ ratingKey │ title             │
└───────┴───────────┴───────────────────┘

  skipped  Fred  (source user)
  created  Alice  (Movie Night)
  created  Bob  (Movie Night admin copy)
  skipped  Cara  (both 'Movie Night' and 'Movie Night admin copy' already exist)
  skipped  Dan  (access denied (401) - no libraries are shared with 'Dan')

Lines appear as each user completes, so a long run shows progress. In a machine-readable format the outcomes come back as one record per user.

Without -o/--overwrite, a copy that collides with both Mix and Mix admin copy is skipped rather than overwriting anything.

Watched-state sync

plexdo copy-watched 7 9 --dry-run     # always preview a full-library sync first
plexdo copy-watched 7 9 -1            # one-way: only user 9 is modified
plexdo copy-watched 7 9 -l 3          # a single library
plexdo copy-watched 7 9 --unwatch     # propagate unwatched instead

By default the most recent lastViewedAt wins. With --unwatch, an item unwatched for either user is unwatched for both, and where both have progress the earliest lastViewedAt wins.

Exporting

plexdo export-playlist 0 "Mix" ~/mix.m3u
plexdo export-titles 3 ~/movies.m3u --sort date
plexdo export-titles 5 ~/photos.html --album "Iceland 2019"

M3U files contain Plex server filesystem paths, not HTTP URLs, so they are meant to be read on or from the server's filesystem. Photo libraries export a single self-contained HTML gallery instead.

Where the media is reached by a different route - an SMB share, another mount point, a Windows drive letter - -p/--prefix swaps the server's library root for one that makes sense there:

plexdo export-titles 3 ~/tv.m3u --prefix '\\NAS\media'
plexdo export-playlist 0 "Mix" ~/mix.m3u -p /Volumes/plex
plexdo export-titles 5 ~/photos.html -p smb://nas/pix
/mnt/media/TV/Breaking Bad/S01E01.mkv   ->   \\NAS\media\Breaking Bad\S01E01.mkv

Separators follow the prefix, so a Windows prefix produces backslashes. Note that the library root is what gets replaced, so one prefix maps every library onto the same place; export each library separately if they are mounted apart. A file below no known library root is appended whole, with one warning. --prefix applies to --m3u on the build and list commands too, and to the photo gallery. Without it, exports keep the server's own paths.

Streaming

plexdo read 3 12345 | mpv -
plexdo read 3 12345 > episode.mkv

On Linux with a display attached but no desktop environment, mpv can draw straight to the console through the kernel modesetting driver, so playback needs neither X nor Wayland:

plexdo read 3 12345 | mpv --vo=drm -

Run that from a virtual terminal rather than a terminal emulator, and make sure your user can reach the DRM device (usually via the video group).

Status

plexdo status                      # everything, as a set of tables
plexdo status --section sessions   # just one section
plexdo status -f json              # nested object, all sections
plexdo status --section tasks -f csv

Reports server identity (name, version, machine ID, platform, platform version, last updated), active sessions with the library, ratingKey, and the file each player has open, shared users, system accounts, reachable addresses, library scans in progress, other background activity, and scheduled maintenance tasks.

A section that cannot be read - reachable addresses need a plex.tv round trip, for instance - is reported as a warning and left empty rather than losing the whole report. --format csv and --format clixml are flat by nature and need --section, since the eight sections have different shapes.

Server management

plexdo rescan --status      # active scan jobs and progress
plexdo rescan 3             # scan one library for new files
plexdo rescan 3 --now       # cancel pending scans first

Shell completion

Completion is provided for bash, zsh, fish, and PowerShell. All three cover user and library IDs and their titles, rating keys, playlist names and keys, and photo album names, reading a 15-minute cache under ~/.cache/plexdo that the list commands populate as a side effect. Numeric IDs are shown with the title they belong to - 7 (Alice), 101 (Breaking Bad - Pilot) - and only the ID is inserted once you narrow to one. When a cache is stale it refreshes in the background, so completion never blocks.

make install sets all three up for you. To manage them separately:

make install-completion                    # bash, zsh, and fish
make install-completion SHELLS="zsh fish"  # only the ones you want
make install-completion PREFIX=/usr/local  # system-wide instead of per-user
make uninstall-completion
shell default destination
bash ~/.local/share/bash-completion/completions/plexdo
zsh ~/.local/share/zsh/site-functions/_plexdo
fish ~/.config/fish/completions/plexdo.fish
PowerShell ~/.local/share/powershell/Completions/plexdo.ps1

For zsh the directory must be in your $fpath before compinit runs:

fpath=(~/.local/share/zsh/site-functions $fpath)
autoload -Uz compinit && compinit

Installing from PyPI rather than a checkout, the script ships as package data:

source "$(python3 -c 'import plexdo,pathlib;print(pathlib.Path(plexdo.__file__).parent/"data/plexdo.bash")')"

PowerShell has no drop-in completion directory, so dot-source the file from your profile:

New-Item -Type File -Force $PROFILE      # if you have no profile yet
Add-Content $PROFILE ". $HOME/.local/share/powershell/Completions/plexdo.ps1"

It needs no Python: PowerShell reads the cache with ConvertFrom-Json, and completions carry the matching title as a tooltip.

The bash script needs no bash-completion package - only bash 4+ and python3. The zsh and fish scripts likewise depend only on python3.

Layout

src/plexdo/
├── cli.py            argument parsing and dispatch
├── commands/         one module per command group, each exposing
│                     register(), COMMANDS, and REQUIRES_PLEX
├── config.py         config, token, and permission checks
├── console.py        JSON and box-drawn table output
├── accounts.py       per-user servers and account classification
├── playlists.py      playlist resolution and copy naming rules
├── airdates.py       air-date estimation
├── gallery.py        photo gallery generation
└── ...

Adding a command means adding a module under commands/ and listing it in commands/__init__.py; cli.py needs no change.

Development

pip install -e ".[dev]"
pylint src/plexdo          # expected: 10.00/10
python -m build            # sdist + wheel

Manual

A full man page ships with the project and is installed by make install:

man plexdo
man -l man/plexdo.1     # straight from a source checkout

It documents every command, the configuration and token file formats, exit status, and the environment variables consulted.

Platform support

Linux, macOS, and Windows are all supported.

notes
Linux Reference platform.
Python 3.11 through 3.14.
macOS Completion scripts fall back to BSD stat -f %m, and the bash script avoids mapfile so it works with the bash 3.2 that macOS still ships. make uses only portable install -d / install -m.
Windows Tables fall back to ASCII box characters when the console encoding cannot represent Unicode ones (a legacy cp1252 console would otherwise abort with UnicodeEncodeError), and characters a title contains but the console cannot render are substituted rather than crashing. POSIX permission checks and chmod are skipped, since Windows uses ACLs and os.stat reports a synthetic mode that would trip the check on every run.

Two caveats specific to Windows. %TEMP% is per-user but is not protected by file permissions and Windows may clear it, so protecting the token file is your responsibility there: chmod cannot restrict it, so place it somewhere your user account alone can read. And --password scrubbing is best-effort - the Py_GetArgcArgv trick that hides the value from ps has no Windows equivalent, so the value stays visible in the process list; a warning says so. Use the config file or the interactive prompt instead.

The bash, zsh, and fish completions all fall back to python where python3 is absent, which matters under Git Bash.

Contributing

See CONTRIBUTING.md. In short: make develop, then make check before opening a pull request.

Security issues: see SECURITY.md.

Releasing

Tagging a vX.Y.Z release publishes to PyPI through Trusted Publishing; see CONTRIBUTING.md.

Links

License

GNU General Public License v3.0 or later - see LICENSE.

This program is free software: you may redistribute and/or modify it under the terms of the GPL as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the LICENSE file for details.

Note that this is a copyleft licence: derivative works and redistributions, including modified versions, must also be released under the GPL-3.0-or-later.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

plexdo-1.1.22.tar.gz (164.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

plexdo-1.1.22-py3-none-any.whl (118.5 kB view details)

Uploaded Python 3

File details

Details for the file plexdo-1.1.22.tar.gz.

File metadata

  • Download URL: plexdo-1.1.22.tar.gz
  • Upload date:
  • Size: 164.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for plexdo-1.1.22.tar.gz
Algorithm Hash digest
SHA256 5e1a97506ad05316f02ff930f07a45dbad73747322e7d3cdf01056e0c404ba10
MD5 ee7a3b308a344a2d9b7935edc4100edf
BLAKE2b-256 113b789a561ae8eddd9badabede9a155a3e3f3be49d661eb0a497dfd767353ba

See more details on using hashes here.

Provenance

The following attestation bundles were made for plexdo-1.1.22.tar.gz:

Publisher: publish.yml on sidusnare/plexdo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file plexdo-1.1.22-py3-none-any.whl.

File metadata

  • Download URL: plexdo-1.1.22-py3-none-any.whl
  • Upload date:
  • Size: 118.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for plexdo-1.1.22-py3-none-any.whl
Algorithm Hash digest
SHA256 477ccb3b8dfc348a17f7dd4f17bf3a768006798aab8c82d800597f67e9dcb943
MD5 0682fec100148980a6bc35e60a5bf079
BLAKE2b-256 501c4ffac64ad3a274c81cc01003cbeedc6c4174fa91e03226f53fefa121fc52

See more details on using hashes here.

Provenance

The following attestation bundles were made for plexdo-1.1.22-py3-none-any.whl:

Publisher: publish.yml on sidusnare/plexdo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

1.1.22 This release

2 files

1.1.18

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page