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. It
refuses to replace a config file that already exists unless given
-o/--overwrite, and --dry-run only says where 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.
plexdo logout signs every saved token out of plex.tv, so it stops working
wherever a copy of the file might be, and removes it from the file;
plexdo logout -u bob@example.com does just that one, and is an error if the
file holds no such name. A token plex.tv already refuses is removed too. One
that could not be signed out, because plex.tv was unreachable, is kept so a
second run can finish, and the exit status is 1. A file left empty is
deleted.
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
plexdo build-concatenated 0 "All" "Part 1" "Part 2" # joined end to end
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. A film with no release date is prompted for too. Without a
terminal to answer on, it stops and names the item that needs a date.
build-concatenated joins playlists end to end, keeping the order within each
and the order they were named in. Every source is read before anything is
written, so a source may also be the destination:
plexdo build-concatenated 0 "Mix" "Mix" "New Finds" -o # append one to another
Duplicates are kept by default, since a plain concatenation is faithful and
Plex permits a repeat; --unique keeps only an item's first appearance,
matching on ratingKey rather than title, and reports how many it skipped. An
empty source is reported and contributes nothing - a typo that resolved to the
wrong playlist otherwise looks exactly like a playlist that happens to be
empty.
Who the playlist is built for
All four build commands take -u/--user and -a/--all-users. Without either,
the playlist lands where it always has: on the account the items came from.
plexdo build-interleaved "Mixed Run" 101 202 -u 7 # just for user 7
plexdo build-interleaved "Mixed Run" 101 202 -u Bob # a title works too
plexdo build-interleaved "Mixed Run" 101 202 -a # for everyone
With either flag the item list is printed once, and then one line per user as that user completes, so a long run shows progress:
Mixed Run (42 items)
┌───────┬───────────┬───────────────────────────┐
│ index │ ratingKey │ title │
└───────┴───────────┴───────────────────────────┘
created admin (Mixed Run)
created Alice (Mixed Run)
skipped Bob ('Mixed Run' already exists (ratingKey 4120); --overwrite would replace it)
skipped Cara (access denied (401) - no libraries are shared with 'Cara')
failed Dan (500 Internal Server Error)
Nothing that goes wrong for one user ends the run. A name already taken is a
skipped, a user the server will not act for is a skipped, and anything
else is a failed; the remaining users are served either way. -o/--overwrite
turns the name collisions into replaced, and --dry-run reports what each
user would get without writing anything. In a machine-readable format the
outcomes come back as one record per user instead of the item list.
-a includes the admin account, since it is a user of the server too. -u
and -a are mutually exclusive.
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 clean-playlist 0 "Mix" # drop what has been watched
plexdo remove-playlist 0 "Mix"
clean-playlist removes the watched entries and leaves the rest in their
existing order. It previews what it will drop, numbered by each entry's place
in the playlist as it stands, so the list lines up with what Plex shows rather
than renumbering around the gaps:
┌───────┬───────────┬─────────────────────────────┬─────────┐
│ index │ ratingKey │ title │ state │
├───────┼───────────┼─────────────────────────────┼─────────┤
│ 1 │ 55 │ The Wire - The Target │ played │
│ 4 │ 58 │ The Wire - Old Cases │ played │
└───────┴───────────┴─────────────────────────────┴─────────┘
A part-played item is kept, since it is the one you are in the middle of;
--include-partial drops those too, reported as partial. Watched state is
per-user, so the USER argument decides whose viewing history is consulted.
A smart playlist is refused - its contents come from a filter, not from its
entries - and --dry-run stops after the preview.
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 sessionKey, 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.
Waiting for a play to end
plexdo wait && sudo systemctl restart plexmediaserver # after the credits start
plexdo wait --no-credits # through the credits to the very end
plexdo wait --offset -120 # two minutes before the credits
plexdo wait --offset 60 # a minute after the wait is over
plexdo wait -s 42 # a particular session
plexdo wait -r 12345 # until that video, once started, is over
plexdo wait -r 12345 --now # ...or not at all, if it is not playing
plexdo wait --paused 600 # a 10-minute pause counts as done
plexdo wait --all && sudo reboot # everyone, including what starts meanwhile
wait sleeps until a session's current play is over and then exits 0, so
whatever follows && runs once the viewer is done. By default it follows the
first session listed and waits for whatever that session is playing.
The play is over when playback reaches the start of the end credits, if Plex
has a credits marker for the video, or its end otherwise; --no-credits
ignores the marker. Where a film has two credits markers around a mid-credits
scene, the final one is used so the scene is not cut off. The wait also ends
the moment the video stops playing - the viewer quit, or autoplay moved on to
the next episode - wherever playback had got to.
It polls every 10 seconds (-i), then every second (--fast-interval) from
30 seconds before the end point on, so the end is caught within about a
second. Each delay varies by up to 20% either way. Every poll prints a line:
Waiting for "The Wire - The Target" (Alice) to reach the credits at 55:40.
Alice | playing: The Wire - The Target | waiting for: The Wire - The Target | 55:00 / 58:12 | waited 0:00 | ~0:40 left
Alice | playing: The Wire - The Target | waiting for: The Wire - The Target | 55:15 / 58:12 | waited 0:10 | ~0:25 left
Alice | playing: The Wire - The Target | waiting for: The Wire - The Target | 55:30 / 58:12 paused | waited 0:11 | ~0:10 left
Alice | playing: The Wire - The Target | waiting for: The Wire - The Target | 55:41 / 58:12 reached | waited 0:12 | done
"The Wire - The Target" reached its end point.
With -r, a video that is not playing yet is waited for until it starts, and
each line says what the user is watching instead; --now exits 0 at once
rather than waiting. Without -s, -r follows whichever session plays the
video. status --section sessions lists the sessionKey -s takes.
In a machine-readable format every poll is one record - a line of JSON, a
YAML document, or a CSV row under a single header - flushed as it is written,
so a pipe sees each one immediately. CLIXML cannot be streamed and is refused;
from PowerShell, read the JSON a line at a time with ConvertFrom-Json.
--paused SECONDS counts a play as over once its player has sat paused that
long - someone fell asleep, or walked away. Plex does not say when a pause
began, so the count starts at the first poll that sees it; resuming starts it
again, and 0 ends the play at the first sight of a pause. While paused, the
estimate is the time the pause has left.
--all waits for every play by every user, each judged by the same rules:
its own credits or end, --offset, --no-credits, and --paused. Each poll
prints a line per session, and a session that disappears is reported once as
stopped. It ends only when two polls an interval apart both find nothing
left, so a play started in the meantime - an autoplayed next episode, or
someone new - is one more to wait for. Nothing playing at all still takes that
second look before exiting. --all takes neither -s nor -r.
A poll that fails is retried; five failures in a row end the wait with an
error. Interrupting it exits 130, so a command chained with && does not run.
Playing on a player
plexdo player-remote-play 10.20.0.40 881 # a film on the TV at that address
plexdo player-remote-play livingroom-tv.lan 881 # resolved here; the TV sees an IP
plexdo player-remote-play livingroom-tv.lan 881 --dry-run --json | jq '{account, fetchFrom}'
player-remote-play connects straight to a Plex player and tells it to start
playing an item at once, as casting from a Plex app on the same network does. The player must allow remote
control (Advertise as player in most Plex apps), but the server need not know
of it.
The player is addressed by IP only. Some players refuse a request whose Host header carries a name, as a defence against DNS rebinding, so a host name is resolved by plexdo and only the address goes on the wire; each address the name has is tried until a player answers.
Each Plex app listens on its own port: 32500 for Android and Fire TV, 8324 for
Roku, 32433 for Plex HTPC, and others besides. Unless --port gives it, plexdo
asks the player over GDM, Plex's discovery protocol, and a player that does not
answer (GDM is IPv4 only, and firewalls may drop it) is tried on those common
ports in turn.
A player takes commands from an account it trusts, normally the one signed in
to it. The command is sent as plexdo's own account when that is the server's
admin or is signed in to the player; otherwise as the account from a
[<user_id>] section that the player is signed in to; otherwise as plexdo's
own account anyway, with a warning.
The item is fetched as the account sending the command too, because the player
plays with the play queue and token of whoever fetched it and switches its own
user to match. When the server refuses an account's own token, as it does a
managed user's, the token the server issues for that user is used instead; when
it will have neither, --player-user is an error, and otherwise a warning says
the player will switch to plexdo's account.
--player-user names that account instead, by plex.tv user id, username, or
email, overriding all of the above, the admin shortcut included:
plexdo player-remote-play livingroom-tv.lan 881 --player-user bob@example.com
It must be plexdo's own account (0 means it when plexdo is the admin, as
everywhere else), a member of plexdo's Plex Home, or have a [<user_id>]
section with credentials; anything else is an error rather than a quiet
fallback to another account.
A Home member, including a managed user, which list-users shows as home or
managed, needs no password: plexdo switches into it through plex.tv, as a
Plex app's user switcher does. A managed user has no username or email, so name
it by id or by the name list-users shows:
plexdo player-remote-play livingroom-tv.lan 881 --player-user Clare
A member with a PIN needs it in a section named for its id:
[826699965]
pin = 1234
For anyone else, a section whose id or username is the value is used
directly; otherwise the sections are signed in one at a time and compared by
id, username, and email.
The player then fetches the item from the server, and the address it is given
has to work from the player's network, not plexdo's. Every address the server
has is a candidate - the configured [plex] url, and each direct address
plex.tv lists - and the one most like the player's own address wins:
- the same family, IPv4 for an IPv4 player and IPv6 for an IPv6 one;
- the same scope, private (RFC 1918, ULA, carrier-grade NAT) or global;
- the longest shared prefix, which picks the server's interface nearest the player on a routed network.
So a player on another routed private network gets the server's private
address nearest it, and a player at another IPv6 site, behind router ACLs
permitting direct connections, gets the server's global IPv6 address rather
than a ULA that would not be routed. Loopback, link-local, and relayed
addresses never qualify, and plex.tv addresses are sent by their plex.direct
names so https certificates check out. --server-url gives the address
outright when the networks are joined some way the ranking cannot see, such as
NAT. plexdo does not test the chosen address: from another segment, a test
would only say what plexdo can reach, not what the player can.
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.
Each command also has a detailed page of its own, covering every option, how
options conflict with or depend on one another, what each global option means
for that command, its output, and worked examples. They are written in
scdoc source format under man/:
plexdo-COMMAND.1.scd for each command, and plexdo-conventions.7.scd for
what they share - naming users and libraries, output formats, path
rewriting, pacing, and exit status. With scdoc installed:
scdoc < man/plexdo-wait.1.scd | man -l -
for f in man/*.scd; do scdoc < "$f" > "${f%.scd}"; done # build them all
The same pages are published to the project wiki,
one wiki page per command, kept in step with main by
.github/workflows/wiki.yml. make wiki builds them locally into
build/wiki/, and needs pandoc as well as scdoc.
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.
Metadata
Release files for plexdo 1.1.29
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| plexdo-1.1.29.tar.gz | 296.5 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| plexdo-1.1.29-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 454.6 kB
Release files / plexdo-1.1.29.tar.gz
| Download URL | plexdo-1.1.29.tar.gz |
|---|---|
| Size | 296.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
49d898b8ca6cf5699ced1a2f4577d8249ac4c630364d50abf7d6fac1265d1593
|
|
BLAKE2b-256 checksum How to use checksums |
192152b640a5734da8cf538c519992f858e56f724318f65c44e0e7e31c5ed704
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logRelease files / plexdo-1.1.29-py3-none-any.whl
| Download URL | plexdo-1.1.29-py3-none-any.whl |
|---|---|
| Size | 158.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f4d9637e0775d1cd61eabd4882987950e744485c3d318a1a78c4c0844d7b085d
|
|
BLAKE2b-256 checksum How to use checksums |
3bf67c684778ba654b0fa56780e9d7ecaade7eed3ecb121108a05a2a8775fc94
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logSecurity for plexdo 1.1.29
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for plexdo-1.1.29.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.