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:
- the server-scoped token the admin can mint for them
- a token already saved in the token file
- 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
- Homepage and source: https://github.com/sidusnare/plexdo
- Issue tracker: https://github.com/sidusnare/plexdo/issues
- Changelog: CHANGELOG.md
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5e1a97506ad05316f02ff930f07a45dbad73747322e7d3cdf01056e0c404ba10
|
|
| MD5 |
ee7a3b308a344a2d9b7935edc4100edf
|
|
| BLAKE2b-256 |
113b789a561ae8eddd9badabede9a155a3e3f3be49d661eb0a497dfd767353ba
|
Provenance
The following attestation bundles were made for plexdo-1.1.22.tar.gz:
Publisher:
publish.yml on sidusnare/plexdo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
plexdo-1.1.22.tar.gz -
Subject digest:
5e1a97506ad05316f02ff930f07a45dbad73747322e7d3cdf01056e0c404ba10 - Sigstore transparency entry: 2633591807
- Sigstore integration time:
-
Permalink:
sidusnare/plexdo@64c47dd4aa9212a8c5e515770f7634eb173fa807 -
Branch / Tag:
refs/tags/v1.1.22 - Owner: https://github.com/sidusnare
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@64c47dd4aa9212a8c5e515770f7634eb173fa807 -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
477ccb3b8dfc348a17f7dd4f17bf3a768006798aab8c82d800597f67e9dcb943
|
|
| MD5 |
0682fec100148980a6bc35e60a5bf079
|
|
| BLAKE2b-256 |
501c4ffac64ad3a274c81cc01003cbeedc6c4174fa91e03226f53fefa121fc52
|
Provenance
The following attestation bundles were made for plexdo-1.1.22-py3-none-any.whl:
Publisher:
publish.yml on sidusnare/plexdo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
plexdo-1.1.22-py3-none-any.whl -
Subject digest:
477ccb3b8dfc348a17f7dd4f17bf3a768006798aab8c82d800597f67e9dcb943 - Sigstore transparency entry: 2633591893
- Sigstore integration time:
-
Permalink:
sidusnare/plexdo@64c47dd4aa9212a8c5e515770f7634eb173fa807 -
Branch / Tag:
refs/tags/v1.1.22 - Owner: https://github.com/sidusnare
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@64c47dd4aa9212a8c5e515770f7634eb173fa807 -
Trigger Event:
release
-
Statement type: