SPDX-FileCopyrightText: 2026 SidusNare
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-FileContributor: Claude Opus 5.5 (AI-generated)

REUSE-IgnoreStart

# AI.prompt.txt

This file is the complete prompt that regenerates this project from scratch,
and the authoritative specification for it. When behaviour changes, update
this file in the same commit.

It opens with a preamble of requirements for every project of its author's;
the project-specific instructions after it take precedence where the two
differ, and each such difference is stated where it arises.

Much of what follows is stated as a constraint with the reason attached. The
reasons matter: nearly every one records a bug that was shipped and then
found, and a regeneration that ignores them will reintroduce it.

---

## PREAMBLE

These requirements apply to all code, configuration, data, and documentation
produced for this project. The key words MUST, MUST NOT, SHOULD, and MAY are
to be interpreted as described in RFC 2119. Project-specific instructions
later in this prompt take precedence over this preamble. If any requirement
cannot be met, state this explicitly and explain why; never silently omit it.

TOOLING
- If a task needs a host tool that is not installed but is available from
  the platform's native package manager (e.g., apt on Debian/Ubuntu, dnf on
  Fedora, pkg on FreeBSD, Homebrew on macOS) at a version that meets the
  requirement, STOP and ask the user to install it. Name the tool, the
  package that provides it, and the install command (e.g., sudo apt install
  shellcheck).
- SHOULD NOT synthesize a workaround. This includes reimplementing the tool,
  substituting a different tool, downloading binaries or scripts from other
  sources, or installing a host tool through a language package manager
  (pip, npm, cargo, gem) instead of the native package, unless the user
  explicitly approves.
- MUST NOT install system packages without the user's approval.
- Resume only after the user confirms the tool is installed or gives other
  direction.
- This applies to host tools, not to project dependencies declared in the
  project's own manifest (e.g., pyproject.toml, Cargo.toml, package.json).

STYLE, FORMATTING, AND LINTING
- Code SHOULD follow the Google style guide for its language where one exists.
  Otherwise, follow the language's dominant community standard (e.g., the
  Rust Style Guide, the Ruby Style Guide).
- Code MUST be formatted with the language's standard formatter, configured
  for Google style where supported.
- Code and structured data MUST pass the following with zero errors.
  Modified or generated linter and formatter configuration files
  MUST be included in the repo. Where a linter default conflicts with the
  style guide, configure the linter to match the style guide.
    Python: ruff check and ruff format; pylint score of 9.5/10 or higher.
    Shell: ShellCheck, with as few warnings as possible.
    JavaScript/TypeScript: ESLint (include the config used).
    Rust: cargo clippy -- -D warnings.
    Ruby: RuboCop.
    C/C++: clang-tidy; clang-format --style=Google.
    Java: Checkstyle with google_checks.xml; PMD.
    Kotlin: ktlint; detekt.
    Swift: SwiftLint.
    Go: gofmt; go vet.
    YAML: yamllint.
    Markdown: markdownlint.
    Dockerfile: hadolint.
- Linter suppressions MUST be as narrow as possible (one line and one rule)
  and MUST include a comment explaining why the issue cannot be fixed.
  ShellCheck: if a directive is needed for the first command in a script,
  place a no-op command (true or :) before it, because a directive before
  the first command applies to the entire file.
- If tools are available, run the linters and report the results. If a
  required linter is missing, follow the TOOLING section. If there is no
  execution environment at all, state that the code has not been linted.
  Never claim a linter passes without having run it.

SECRETS AND CONFIGURATION
- Credentials, tokens, and keys MUST NOT be hard-coded or stored in
  plaintext.
- Android: Encrypt credentials and session tokens with an Android
  Keystore-backed key (e.g., AES-GCM) before persisting them.
  EncryptedSharedPreferences MUST NOT be used.
- Apple platforms: Store secrets in the Keychain.
- GNU/Linux, BSD, and similar systems: Use YAML (.yaml) for configuration
  and JSON for structured data files.
- Service daemons should store configuration in /etc/<project name>/ .
- User space applications SHOULD use config files located per the XDG Base
  Directory Specification. Secrets MUST use the Secret Service API where
  available.
- Configuration files SHOULD include comments explaining each option's
  meaning, valid values, and default.

VERSIONING
- The project MUST use Semantic Versioning 2.0.0 (https://semver.org/spec/v2.0.0.html):
  MAJOR.MINOR.PATCH, with optional pre-release (-alpha.1) and build
  metadata (+20261027) as defined by the specification.
- The public API MUST be documented, since SemVer is defined against it.
  For applications without a programmatic API, the public API includes
  command-line options, configuration file format and options, structured
  data file formats, environment variables, and exit codes.
- Initial development MUST start at 0.1.0. Version 1.0.0 marks the first
  stable public API.
- The version MUST be defined in one place (e.g., pyproject.toml,
  Cargo.toml, package.json) and derived from there wherever else it
  appears. The SBOM package version MUST match it.
- When making changes, state which version component they require bumping
  and why: MAJOR for incompatible API changes, MINOR for backward-compatible
  additions, PATCH for backward-compatible fixes.
- Release tags SHOULD use the form vMAJOR.MINOR.PATCH (e.g., v1.4.2). The
  leading "v" belongs to the tag only, not to the version itself.
- Where a platform or ecosystem imposes its own version format, map the
  SemVer version to it and document the mapping. Examples: Python
  pre-releases normalize per PEP 440 (1.0.0-alpha.1 becomes 1.0.0a1);
  Android also requires an integer versionCode; Apple's
  CFBundleShortVersionString does not allow pre-release suffixes.
- The project MUST maintain CHANGELOG.md following Keep a Changelog 1.1.0
  (https://keepachangelog.com/en/1.1.0/):
    - Entries are written for users, not copied from commit messages.
    - Every change MUST be recorded under an "Unreleased" section at the
      top, grouped as Added, Changed, Deprecated, Removed, Fixed, or
      Security.
    - On release, the Unreleased entries move under a new heading of the
      form "## [MAJOR.MINOR.PATCH] - YYYY-MM-DD", newest release first.
    - Breaking changes MUST be clearly marked, and the version bump MUST be
      consistent with the entries (e.g., anything under Removed, or an
      incompatible Changed entry, requires a MAJOR bump after 1.0.0).
    - Version headings SHOULD link to a comparison of the release tags
      where the repository host supports it.
- Markdown lint configuration MUST set MD024 (no duplicate headings) to
  siblings_only: true, since each release repeats the same section names.

LICENSING AND PROVENANCE
- License the project GPL-3.0-or-later. LICENSE MUST be an exact copy of
  https://www.gnu.org/licenses/gpl-3.0.txt. If you cannot fetch it, do not
  reproduce it; instead instruct the user to run:
    curl -o LICENSE https://www.gnu.org/licenses/gpl-3.0.txt
- The project MUST be REUSE-compliant and pass `reuse lint`. Every file
  carries SPDX-FileCopyrightText and SPDX-License-Identifier headers; files
  that cannot hold comments (e.g., JSON) use a REUSE.toml entry.
- Every file written wholly or partly by AI MUST include the header:
    SPDX-FileContributor: <model name and version> (AI-generated)
- Provide an SPDX 3.0 SBOM listing all dependencies. Record the AI model(s)
  used to generate the code in the SBOM creation info. If the software
  includes or depends on AI models, describe them using the SPDX AI profile
  and link to the publisher's model cards. Do not invent checksums or
  versions; leave unknown fields to be populated by an SBOM tool.

PROMPT RECORD
- Create and maintain AI.prompt.txt containing a self-contained prompt that
  would regenerate this software. It MUST include this preamble, including
  the requirement to create and maintain AI.prompt.txt. Update it whenever
  requirements or features change.

---

Build `plexdo`, an installable Python package providing a command-line
interface to Plex Media Server via the `plexapi` library, ready to publish to
PyPI.

## IDENTITY

- Distribution, import package, console script, man page, and completion files
  all share the single name `plexdo`. The project was once called `plex.do`;
  that name is retired and must not appear anywhere.
- There is exactly one console script: `plexdo = "plexdo.cli:main"`.
- Homepage and source: `https://github.com/sidusnare/plexdo`
- Issues: `https://github.com/sidusnare/plexdo/issues`
- Author: `SidusNare`, in `[project.authors]`, the GPL copyright line of
  `__init__.py`, `__main__.py`, and `cli.py`, and the man page AUTHORS
  section. No email is published; PEP 621 allows a name alone.
- Licence: GPL-3.0-or-later. `LICENSE` is the text of
  `https://www.gnu.org/licenses/gpl-3.0.txt` byte for byte (674 lines,
  SHA-256 `3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986`),
  and `LICENSES/GPL-3.0-or-later.txt` is the same file, where REUSE looks for
  it. `pyproject.toml` declares the SPDX expression and **no** `License ::`
  trove classifier, which modern setuptools rejects alongside it. The three
  entry-point modules also carry the full GPL notice.

### REUSE

The project passes `reuse lint`. Every file carries, in its own comment
syntax, `SPDX-FileCopyrightText: 2026 SidusNare`,
`SPDX-License-Identifier: GPL-3.0-or-later`, and, since every file but the
two below was written with AI, `SPDX-FileContributor: Claude Opus 5.5
(AI-generated)`. Only that model is named: models used before it was
recorded are not known. Placement follows the format:

- after a `#!` line, and after zsh's `#compdef plexdo`, which must stay first;
- in an scdoc page, as `; ` comments directly after the preamble line, which
  scdoc requires first; a blank line between them adds a stray `.PP`;
- in roff as `.\" ` comments, and in Markdown as one `<!-- -->` comment,
  which markdownlint's first-line-heading rule accepts;
- in this file as plain lines, with the rest between `REUSE-IgnoreStart` and
  `REUSE-IgnoreEnd`, since the preamble quotes SPDX tags that `reuse lint`
  would otherwise read as this file's own.

`REUSE.toml` covers what cannot hold a comment: `package.json`,
`package-lock.json`, `sbom.spdx.json`, and `src/plexdo/py.typed`, which stays
empty for type checkers; and the author's own `AI.policy.txt` and `TODO`,
which were not written with AI and carry no contributor line. REUSE.toml has
no contributor field, so the JSON files' AI origin is stated in a comment
there and, for the SBOM, in its creation information.

### SBOM

`sbom.spdx.json` is an SPDX 3.0.1 JSON-LD document written by
`tools/derive.py` from `pyproject.toml` and `package.json`, never by hand. It
records the package, its declared licence, and every declared dependency
with a `LifecycleScopedRelationship` of scope `runtime`, `build`, or
`development`, each dependency carrying the requirement string as a comment.
No dependency version or checksum is given: what is installed is resolved
at install time, and transitive dependencies are left for an SBOM tool run
against an installation. `CreationInfo.createdUsing` names the AI model as a
`Tool`, and its comment says plexdo neither includes nor depends on an AI
model, so the SPDX AI profile does not apply. `created` is the release date
from CHANGELOG.md, so the file is reproducible. It validates against
`https://spdx.org/schema/3.0.1/spdx-json-schema.json`.

## VERSIONING

Semantic Versioning 2.0.0. The version is defined **only** in
`pyproject.toml`; everything else derives from it:

- `plexdo.__version__` reads `pyproject.toml` beside the package when run
  from a source checkout (checking `[project] name` is `plexdo`), so a
  version bumped since the last editable install is never reported stale,
  and otherwise `importlib.metadata.version("plexdo")`.
- `tools/derive.py` writes the `.TH` line of `man/plexdo.1` and its packaged
  copy, and `sbom.spdx.json`; `make derive` runs it and `make check` runs it
  with `--check`, which fails naming any file out of date. The `.TH` date is
  the release date from CHANGELOG.md, kept as it is while unreleased.

The project was already past 1.0 when the preamble was adopted, so its rule
that development starts at 0.1.0 does not apply. A pre-release maps to PEP 440
in `pyproject.toml` (`2.0.0-alpha.1` is written `2.0.0a1`, `2.0.0-rc.1`
`2.0.0rc1`), and so in `__version__`, the man page, and the SBOM, which all
show what PyPI shows. The tag and the CHANGELOG.md heading use the SemVer
spelling; `tools/derive.py` maps one to the other to find the release date,
and `publish.yml` compares tag and package with `packaging.version.Version`,
which reads both spellings as the same version, never as text. Tags are
`vMAJOR.MINOR.PATCH`, with a pre-release suffix where there is one, and the
`v` on the tag alone.

**A change does not bump the version.** It is recorded under `## [Unreleased]`
in CHANGELOG.md, and the change's description says which component it will
need: MAJOR for anything incompatible in the public API, MINOR for a
compatible addition, PATCH for a compatible fix. The bump happens at release,
on the author's instruction, by the component the Unreleased entries call
for, moving them under `## [X.Y.Z] - YYYY-MM-DD`. The public API is
everything documented in the man pages: commands, options, the
configuration file and its options, the secret store's layout, structured
output fields, environment variables, and exit status.

CHANGELOG.md follows Keep a Changelog 1.1.0, written for users, grouped as
Added, Changed, Deprecated, Removed, Fixed, and Security, with every
incompatible entry starting **Breaking:**. Each version heading links to the
comparison of its tag with the previous release's,
`compare/vPREVIOUS...vTHIS`, and `[Unreleased]` to `compare/vLATEST...HEAD`;
a release never tagged leaves its link dead until it is. The `Version="1.1.0.1"`
attribute in CLIXML output is a fixed PowerShell schema constant and has
nothing to do with this version.

## GENERAL REQUIREMENTS

- Python 3.11 through 3.14.
- Every function fully annotated; the package ships `py.typed`.
- Google Python style, enforced by ruff configured in `pyproject.toml`:
  80 columns, rule sets E, W, F, I, D (Google docstring convention), B, and
  UP, so `X | None` and builtin generics rather than `typing.Optional` and
  `typing.List`. `D107` is off, since Google documents construction in the
  class docstring; tests are exempt from the docstring rules, since Google
  asks them only of what is externally visible. `ruff check` and
  `ruff format --check` pass over `src`, `tests`, and `tools`.
- pylint 10.00/10 across the package, and `vulture src/plexdo
  --min-confidence 0` empty. Scope vulture to `src`: test fixtures are
  consumed reflectively through `vars()`, so it cannot see their attributes
  being used. The formatter puts each keyword argument on its own line, which
  makes repeated `add_argument` calls trip pylint's `duplicate-code`: an
  argument several commands share is defined once, in `arguments.py`.
- ShellCheck passes on `tools/wiki-pages.sh` and `completions/plexdo.bash`
  (zsh and fish are not shells ShellCheck reads); yamllint, configured in
  `.yamllint.yaml` at 80 columns with `truthy` keys exempt because GitHub
  Actions requires the key `on`, passes on every YAML file and on the config
  template plexdo writes; markdownlint-cli2, pinned in `package.json` at its
  newest release (0.24.0), passes on every Markdown file with
  `MD024 siblings_only` and `MD013` at 80 columns, code blocks and tables
  exempt. It needs Node 22 or newer, which `package.json` declares in
  `engines` and the Makefile checks before running it, naming `.nvmrc`;
  `.nvmrc` holds the Node major CI uses (24, the LTS line). Where the
  platform's Node is older, as Debian stable's 20 is, a developer runs the
  official build from nodejs.org, its `SHASUMS256.txt` signature checked
  against the Node.js release keys, rather than holding the linter back to
  an old release.
- The npm tree stays consistent: no `overrides` forcing a package outside
  the range its dependent declares. A vulnerability in a development-only
  npm dependency is fixed by upgrading the package that pulls it in. One
  that no consistent tree avoids is left, with the reason recorded here:
  KaTeX's GHSA-238p-pmpm-9mq7 (low), reached through
  `markdownlint -> micromark-extension-math`, whose `^0.16` range no patched
  KaTeX release satisfies. markdownlint imports only that package's syntax
  extension; its KaTeX renderer is loaded and never called, and plexdo
  ships none of it.
- A linter suppression covers one line and one rule, with a comment saying
  why the code cannot change instead.
- One Google rule is knowingly not followed: its guide imports modules, not
  the names inside them (`from plexdo import config`, then
  `config.load_config()`), and this codebase imports names throughout. No
  linter checks it, the preamble makes the guide a SHOULD, and converting
  every import is churn with no behaviour behind it. New code follows the
  surrounding code.
- Small single-purpose functions, no dead code, no duplicated logic, no
  global mutable state, no bare `except`.

### Naming

A leading underscore means **module-private**. A helper another module imports
is package API and must not carry one, or the convention says nothing.

**No two modules may define the same name.** The import tooling keeps one
entry per symbol, so a duplicate makes it inject a cross-module import that
silently shadows the local definition. That is how `records._scalar` came to
be overridden by `formats._scalar`, quietly disabling nested media expansion.
Name functions for what they do: `_cell_value` flattens a value for a tabular
cell, `_jsonable` preserves structure.

A constant referenced only by its own test is ceremony, not a source of truth;
assert the literal in the test instead.

**Name nothing like a secret that is not one.** CodeQL's
`py/clear-text-logging-sensitive-data`, like other scanners, decides what is
sensitive from names: a variable, attribute, parameter, or function matching
`secret` or `password` (`pass(wd|word|code|phrase)`), or a lookup by such a
string, is a secret to it, and so is everything computed from one, until it
reaches a log call or `print` and is reported as a leak. A name containing
`file`, `path`, or `url` is exempt. So the secrets file's path is
`Paths.secrets_file`, never `Paths.secrets`; the count of forgotten saved
passwords comes from `forget_logins`, not `forget_passwords`; the
`--save-password` flag's dest is `remember_login`; the migration's counts are
variables of their own rather than `moved["passwords"]`; and the tuples of
option names are `STORE_CHOICES`, `_KEYRING_OPTIONS`, and
`_SCRUBBED_OPTIONS`. The converse matters as much: what does hold a password
or a token keeps a name saying so (`password`, `saved_password`, `secrets`),
so that a real leak is still reported. The public option names
`secret_store` and `secrets_file` are API and do not change.

### Character set

Source, completions, man page, and build files are **plain ASCII**. Write `-`
not an em dash, `->` not an arrow, `>=` not the inequality sign, `...` not an
ellipsis, a straight `'` not a curly quote. This applies to comments,
docstrings, and user-facing message strings alike: a warning printed to a
terminal has no business containing an em dash.

Two deliberate exceptions, both about output rather than prose: `console.py`
holds the box-drawing glyphs as `\uXXXX` escapes, so the file itself stays
ASCII while the tables still draw; and the markdown docs keep literal box
characters where they reproduce program output or draw a `tree`-style listing.

## LAYOUT

```
pyproject.toml  Makefile  README.md  LICENSE  LICENSES/GPL-3.0-or-later.txt
REUSE.toml  MANIFEST.in  requirements.txt  CHANGELOG.md  CONTRIBUTING.md
SECURITY.md  AI.prompt.txt  AI.policy.txt  TODO  sbom.spdx.json
package.json  package-lock.json  .markdownlint-cli2.yaml  .yamllint.yaml
.gitignore  .gitattributes  .github/workflows/{ci,publish,wiki}.yml
tools/wiki-pages.sh  tools/derive.py
man/plexdo.1  man/plexdo-conventions.7.scd  man/plexdo-COMMAND.1.scd
completions/{plexdo.bash,_plexdo,plexdo.fish,plexdo.ps1}
tests/conftest.py + test_*.py
src/plexdo/
    __init__.py     __version__
    __main__.py     python -m plexdo
    cli.py          build_parser, main
    constants.py    windows_app_dir, default_paths, Paths, PATHS, CONFIG_PATH,
                    DEFAULT_CACHE_DIR, DEFAULT_SECRETS_FILE, STORE_CHOICES,
                    config_text, CONFIG_EXAMPLE, LOG
    logs.py         configure_logging
    config.py       load_config, parse_config, cached_config, config_optional,
                    user_option, configured_users, expand_environment,
                    check_file_permissions, Config
    secret_store.py backend, open_store, load, save, describe, KeyringStore,
                    FileStore, Store
    tokens.py       load_store, lookup, store_token, admin_token,
                    forget_tokens, saved_password, save_password,
                    forget_logins, saved_pin, save_pin
    connection.py   connect_plex, read_token, server_url
    migrate.py      migrate_legacy_config
    arguments.py    add_user_argument, add_library_argument,
                    add_m3u_argument, add_overwrite_argument,
                    add_rating_keys_argument, add_rating_key_option
    cache.py        cache_dir, write_cache
    convert.py      parse_date, format_duration
    console.py      output, output_format, clean_text, table_limit,
                    print_table, print_metadata, stream_record,
                    STREAMABLE_FORMATS
    formats.py      to_json, to_yaml, to_csv, to_clixml, render
    records.py      loaded_fields, file_paths, release_date, summary_row,
                    cache_row, season_records
    identify.py     resolve_identifier
    accounts.py     server_for_user, account_type, resolve_user_arguments,
                    user_roster, UserAccessError, connect_with_token,
                    connect_as_shared_user, configured_entries,
                    configured_account, configured_accounts
    sections.py     resolve_section, resolve_sections,
                    resolve_library_arguments
    titles.py       display_title, find_item, fetch_item, fetch_show,
                    non_special_episodes, shuffle_list, item_is_played,
                    item_view_offset
    playlists.py    resolve_playlist, finalize_playlist, copy_playlist_to,
                    existing_playlist, preview_rows, require_items,
                    create_for_user
    fanout.py       add_user_target_arguments, deliver_playlist, run_per_user,
                    report_line
    photos.py       collect_photos, collect_library_items, photo_file_path
    sorting.py      apply_sort
    m3u.py          write_m3u
    gallery.py      write_gallery_html
    paths.py        mapper_for, library_roots, add_prefix_argument
    airdates.py     resolve_episode_date and its helpers
    throttle.py     paced, throttle_delay
    playback.py     fetch_address, parse_ip, url_host, url_of, play_fields,
                    add_play_arguments, Candidate, IPAddress
    security.py     scrub_password_argument
    data/           the four completions and the man page, as package data
    commands/       one module per group (see COMMANDS)
```

The imports form no cycle: `config` reads the file and knows nothing of
secrets; `secret_store` uses `config` for its two options; `tokens` uses
`secret_store`; `connection` uses `config` and `tokens`; `migrate` uses
`secret_store` and `constants`, and runs from `cli.main` before any command,
not from `load_config`, which would close the loop.

### Command module contract

Every module in `commands/` exposes exactly three names:

- `register(sub, parents)` adds its subparsers, passing `parents=parents` on
  every `add_parser` call so each inherits the global flags.
- `COMMANDS` maps command name to handler.
- `REQUIRES_PLEX` is the subset needing a server; `auth` has an empty
  frozenset because `login` and `write-config-example` run before a token
  exists, `logout` must work once the server no longer accepts one, and
  `set-pin` only touches the secret store.

`commands/__init__.py` holds a `MODULES` tuple whose order sets the order of
subcommands in `--help`, plus `register_all(sub, parents)` and
`build_registry()`. Adding a command means adding a module and listing it;
`cli.py` never changes.

## PLATFORM DEFAULTS

`constants.default_paths(is_windows, env)` returns a `Paths` tuple: the
config file, the cache directory, the secrets fallback file, and plexdo 1.x's
INI file and default token path, for the migration. Take the flag and
environment as arguments rather than reading `os.name` inside, so both
branches are testable from either platform: forcing `os.name` on Linux makes
`pathlib` fail to build a `WindowsPath`.

| | config | cache | secrets fallback |
| --- | --- | --- | --- |
| Linux, macOS, BSD | `$XDG_CONFIG_HOME/plexdo/config.yaml` | `$XDG_CACHE_HOME/plexdo` | `$XDG_STATE_HOME/plexdo/secrets.json` |
| Windows | `%LOCALAPPDATA%\PlexDo\config.yaml` | `%LOCALAPPDATA%\PlexDo\Cache` | `%LOCALAPPDATA%\PlexDo\secrets.json` |

The XDG variables default to `~/.config`, `~/.cache`, and `~/.local/state`,
and one set to a relative path is ignored, as the XDG Base Directory
Specification requires. The secrets fallback lives in the state directory
because it must survive a reboot, which `XDG_RUNTIME_DIR`, 1.x's choice for
its token, does not. `APPDATA` is the Windows fallback when `LOCALAPPDATA`
is unset. All four shell completions must resolve the same cache location,
or completion silently stops working under Git Bash.

## CONFIGURATION

The configuration file is YAML and holds **no secrets**:

```yaml
---
plex:
  url: "http://localhost:32400"
  # username: "you@example.com"
  # cache_dir: "~/.cache/plexdo"
  # secret_store: "auto"
  # secrets_file: "~/.local/state/plexdo/secrets.json"
users:
  99:
    username: "bob@example.com"
```

- `config.parse_config` reads it with `yaml.safe_load` and checks it into
  `{"plex": {option: text}, "users": {"<id>": {option: text}}}`, every value
  a stripped string and every user id a string of digits. An unknown section
  or option is warned about by name and ignored; `password`, `pin`, `token`,
  or `token_path` is warned about as a secret that belongs in the keyring
  and ignored, so a value carried over from 1.x never reaches use. Malformed
  YAML, a list where a value belongs, a user id that is not numeric, and a
  `secret_store` other than `auto`, `keyring`, or `file` are each a
  `sys.exit` saying what is wrong. A missing `url` is an error where a
  server is needed (`connection.server_url`), not on loading, so
  `logout` and `set-pin` work without one.
- Expand environment variables in `url`, `cache_dir`, and `secrets_file`
  with `os.path.expandvars`, which follows the platform (`%VAR%` on Windows,
  `$VAR` everywhere). An unset name is left literal, which would surface
  much later as a confusing "no such file", so warn about any name still
  present after expansion, in whichever spelling the platform accepts.
- `cached_config()` is memoised with `lru_cache(maxsize=1)`: the per-user
  paths read `users` entries once per user in a loop, and re-reading would
  re-emit every warning each time.
- `constants.config_text(url, username, cache_dir, users)` writes the file
  with **every option explained** - its meaning, valid values, and default,
  the default on a line of its own so a long path is never split - as
  comment paragraphs filled to 76 columns by `textwrap`. Values are quoted
  with `json.dumps`, valid YAML for any text. `CONFIG_EXAMPLE` is
  `config_text()`, written by `write-config-example`; the 1.x migration
  writes it with the values it found, so a migrated file is as documented
  as a fresh one. It opens with `---` and passes yamllint.

## SECRETS AND MULTI-USER ACCESS

Tokens, passwords, and Plex Home PINs are kept by `secret_store.py` in the
system keyring through the `keyring` package: the Secret Service on Linux
and BSD, the Keychain on macOS, and Credential Manager on Windows. Each kind
is **one entry** under service `plexdo`, holding a JSON object: `tokens`
(username -> token), `passwords` (username -> password), `pins` (user id ->
PIN). One entry per kind, not per secret, because a keyring cannot list what
it holds and `logout` must find every token. Saving a kind with nothing left
removes its entry.

- `plex: secret_store` chooses the store. `auto`, the default, uses the
  keyring when `keyring.get_keyring()` is a real one - not `fail.Keyring`,
  nor a `ChainerBackend` with no backends - and otherwise the file, with a
  WARNING that it is unencrypted and how to silence it; `keyring` insists,
  and exits explaining when there is none; `file` never asks the keyring,
  for a machine where it is locked when plexdo runs, as under cron.
- The file, `plex: secrets_file` or the platform default, is one JSON object
  of kind -> object, created with `os.open(..., 0o600)` so it is never
  readable by anyone else even for a moment, replaced atomically, and
  deleted with its last secret. `check_file_permissions` warns when it is
  readable by group or other, at WARNING level so it shows without
  `--verbose` and on stderr so it never contaminates `--json`; it returns
  early on Windows, which has no POSIX mode bits.
- A keyring failure, locked or its prompt dismissed, is a `sys.exit` naming
  `secret_store: file` as the way out.
- `secret_store.backend()` is chosen once per run and works before the
  config exists, with the defaults, since the migration saves secrets
  before writing the file.

`tokens.py` is the only module that knows the three kinds.

- `ADMIN_KEY` is `"@admin"`, for a token saved with no username known; `@`
  cannot occur in a Plex username. `admin_token(username)` resolves in
  order: the configured username, the reserved key, then a store holding
  exactly one entry.
- A password is saved only by `login --save-password`, filed by username so
  it can never be paired with the wrong account.

`accounts.server_for_user(plex, user_id)` returns the admin server for
`user_id == 0`. Otherwise it tries three sources in order and returns the
first that connects, because a server refuses an admin-issued token for a user
it has shared nothing with:

1. `connect_as_shared_user`: `user.get_token(machineIdentifier)` (**never**
   `switchHomeUser()`, which only works for Plex Home and raises 401 for
   ordinary shared users)
2. a token already in the store, under any candidate username
3. `MyPlexAccount(username, password)` for the `users` entry's username with
   its saved password, whose token is then **saved** so the login happens
   once

Each stage returns None rather than raising, so the next is tried and a stale
stored token falls through to a fresh login. Stage 3's sign-in is
`_login_entry`, shared with `configured_accounts()`, which yields each
`users` account signed in - by its stored token while plex.tv accepts it,
else by its saved password, saving the token - one at a time, so a caller
that finds its account early signs in no more. When all three fail, raise
`UserAccessError` explaining that admin rights do not override per-user
scoping and that a `users` entry signed in once with `login --username`
would fix it. It must be an ordinary `Exception`, **not** a `sys.exit`:
`copy-playlist-all-users` catches per-user failures and continues, and
`SystemExit` would tear down the whole run. `cli.main` converts it to a
clean exit for single-user commands. It carries a one-line `summary` so a
loop reports each skip in one line.

### Moving from 1.x

plexdo 1.x read `~/.local/etc/plexdo.ini` (`%LOCALAPPDATA%\PlexDo\plexdo.ini`
on Windows), with passwords and PINs in plaintext, and kept tokens in a JSON
file at its `token_path` (a pre-1.0.4 file holds one bare token, the
admin's). `migrate.migrate_legacy_config()` runs from `cli.main` before every
command, `--dry-run` included since nothing on the server changes, and does
nothing once `config.yaml` exists or when there is no INI file. Otherwise:

1. read the INI with `interpolation=None`, so a `%` in a password does not
   raise, and its token file;
2. merge tokens, passwords (the `[plex]` one filed under `[plex] username`;
   one with no username is dropped with a warning), and PINs into the
   secret store, a secret already saved winning as the newer;
3. write `config.yaml` with `config_text`, exclusively and mode 0600;
4. rewrite the INI without `password` or `pin` as `plexdo.ini.migrated`,
   headed by a comment saying where it went, remove the original, and
   delete the token file;
5. warn once, saying what moved where.

Secrets are saved before anything is removed, so an interrupted run loses
nothing and the next finishes the job.

## IDENTIFIERS

`identify.resolve_identifier(roster, value, kind, list_command)` implements
the rules once for users, libraries, and shows. Do not write it three times.

- Titles match exactly first, then case-insensitively.
- More than one title match aborts, listing the colliding IDs.
- A numeric value that is a real ID wins; if it is *also* another entry's
  title, warn naming the entry not selected.
- A numeric value that is not a real ID but is a title resolves by title.
- A numeric value matching nothing is returned unchanged, so the downstream
  lookup produces the precise error.
- A non-numeric value matching nothing aborts, pointing at the list command.

The duplicate-title abort applies only when resolving by title; a numeric ID
is unambiguous and must still work on a server with two identically titled
entries.

Every argument naming a user or library accepts either form, so those argparse
arguments are plain strings with `metavar="USER"` or `"LIBRARY"` - never
`type=int`. `accounts.resolve_user_arguments` and
`sections.resolve_library_arguments` run from `cli.main` after connecting and
before dispatch, rewriting `user_id`, `user_a`, `user_b`, `source_user_id`,
`dest_user_id`, and `library_id` in place, so handlers always receive an int. Build each
roster once per invocation, and skip the fetch when no such argument is
present.

## GLOBAL FLAGS

`-f/--format`, `--json`, `-v/--verbose`, `--debug`, `--dry-run`, `-W/--wide`,
`--throttle`, `-V/--version`. They must work **either before or after the
command name**.

Implement with `_add_global_flags(parser, suppress=False)` called twice: once
on the top-level parser with ordinary defaults, and once on an
`add_help=False` parent that every subparser inherits via `parents=`.

The SUPPRESS default is essential, not cosmetic. A subparser parses into its
own namespace and then copies **every** attribute onto the main one, so
ordinary defaults on the inherited copies would silently clobber a flag given
before the subcommand: `plexdo --json list-users` would print a table.

`--format` writes to `dest="format"`; `--json` is a `store_const` alias
writing to that same destination, so there is one attribute and no way for the
two to disagree. All logging goes to stderr only.

## OUTPUT

`output(data, args)` dispatches on `output_format(args)`, accepting **either**
a single record or a list so `show-metadata` and `list-titles` share the path.
No command reaches for `json.dumps` directly.

### Formats

| format | notes |
| --- | --- |
| `table` | box-drawn, width-aligned, fitted to the terminal |
| `json` | non-ASCII escaped, so it prints on any console |
| `yaml` | strings **always** double-quoted |
| `csv` | header row, `\n` line endings |
| `clixml` | PowerShell CLIXML with typed properties |

- YAML strings must always be quoted. Plain style would let YAML's type
  guessing turn a title of `NO`, `yes`, or `1.10` into a boolean or a float on
  the way back in. The emitter must be recursive, or nested sections
  serialise as stringified Python dicts.
- CLIXML emits typed elements (`<S>`, `<I32>`, `<B>`, `<Nil>`) inside `<MS>`,
  the first `<Obj>` carrying `<TN>` and later ones `<TNRef RefId="0" />`,
  which is what `Import-Clixml` expects.
- `formats._cell_value` flattens for CSV and CLIXML; `records._jsonable`
  preserves structure for the formats that can express it.
- A command that reports as it goes emits through `console.stream_record`
  instead of `output`: JSON becomes JSON Lines, YAML a `---` separated stream,
  CSV a header before the first record only. **Flush every record**, or a pipe
  sees nothing until the buffer fills. CLIXML cannot stream - `Import-Clixml`
  reads exactly one document - so `STREAMABLE_FORMATS` leaves it out and such
  a command refuses it, pointing PowerShell users at `ConvertFrom-Json`.

### Tables

- `clean_text(value)` is `str(value).strip()`. Plex metadata contains stray
  `\r`, which makes a printed row's trailing pad overwrite the start of the
  line and appear as a blank line after every row.
- `_display_width` uses `unicodedata.east_asian_width`, **not** `len()`: CJK
  glyphs occupy two columns and combining marks none, so a `len()`-padded
  table drifts out of alignment on any non-Latin title.
- `_box()` test-encodes the glyphs against `sys.stdout.encoding` and falls
  back to ASCII `+-|`. A legacy Windows console is cp1252, which cannot encode
  U+2500, and the Unicode-only version raises `UnicodeEncodeError` and prints
  *nothing*. `_printable()` substitutes unencodable cell characters. Apply it
  only in the printers, never in `clean_text`, which feeds title matching:
  mangling an accented name there would break comparisons against real Plex
  data.
- `table_limit(args)` returns the column budget, or `None` when `-W/--wide` is
  given **or stdout is not a tty** - truncating a pipeline would corrupt data
  going to a file or `grep`. `_fit_widths` shrinks the widest column
  repeatedly, stopping at `MIN_COLUMN_WIDTH` and only then at the ellipsis
  itself, so a very narrow terminal degrades rather than overflowing.
  Truncation is display-width aware; report the narrowed columns once at info
  level naming `--wide`.

## THROTTLING

`throttle.paced(items, args, what)` yields items, sleeping `--throttle`
seconds between them once the count passes `THROTTLE_THRESHOLD` (50). Default
0.25s; 0 disables. The pause goes *between* elements, so n items wait n-1
times, and a short run is never paced because the delay would be latency for
no benefit. Announce once at info level with the expected duration and how to
turn it off.

Apply wherever a loop makes one request per element and the count can exceed
50: the seasons walk in `list-titles`, `find-missing -A`, applying watched
state in `copy-watched`, removing entries in `clean-playlist`, reading each
source in `build-concatenated`, `collect_photos`, and the per-user loop in
`fanout.run_per_user`, which serves `copy-playlist-all-users` and every build
command given `-a`. Functions needing it take an optional `args`.

## RECORDS

plexapi exposes `media`, `genres`, `directors`, `collections` and a dozen
others as `cached_data_property`. Those are **absent from `vars()`** until
something first accesses them, so reading only `vars()` reports a fraction of
the metadata and no file paths at all. Take the union of `vars(item)` and
`type(item)._cached_data_properties`.

Reading them must not cost requests, though:
`PlexPartialObject.__getattribute__` reloads whenever a value is `None` or
`[]` and the object is partial, which across a library is one round trip per
item. Set `item._autoReload = False` while reading and restore it afterwards;
that flag short-circuits the reload. Note plexapi returns underscore
attributes without reloading, so a test fake modelling this must explode only
for public names.

`records._media_versions` reads the XML plexapi already holds in `_data`
rather than the `media` property: it is always accurate, needs no reload
guard, and carries the `selected` flags that identify the version and part a
session is actually playing.

`_jsonable` expands nested plexapi objects to a depth of 2 rather than
`str()`-ing them, or `media` serialises as `<Media object at 0x...>` and the
file names appear nowhere. Cap the depth and guard `vars()` with `TypeError`,
since a self-referential object or one using `__slots__` would otherwise
recurse or raise. Reduce tag objects to their `.tag` and dates to text.

`records.file_paths(item)` is the **only** media traversal in the project:
M3U export, the photo gallery, `show-metadata`, and `loaded_fields` all use
it. An item may have several versions each with several parts, so it returns a
list. `loaded_fields` also lifts the paths to a top-level `files` key, because
they are otherwise three levels down.

`sourceURI` is not a file name: plexapi sets it from the `source` attribute,
the remote server URI of an item in another user's playlist, null for local
content.

## PLAYLISTS

Every argument naming an **existing** playlist accepts a title or a ratingKey
through `resolve_playlist`; no command may call `plex.playlist(name)`
directly. A numeric value that is no item at all is a clean `sys.exit`, not
plexapi's `NotFound` escaping as a traceback: look it up with
`titles.find_item`, which returns None, and `fetch_item` wraps that for every
other fatal lookup, `fetch_show` included. Nothing calls `fetchItem` bare.

Every build command constructs the list fully in memory, validates it is
non-empty, prints a numbered preview, and makes exactly one `createPlaylist`
call. `finalize_playlist` enforces this and returns `"created"` or
`"replaced"`.

There are two ways in: `finalize_playlist`, which **exits** on a refused name
collision, and `create_for_user`, which **reports** one as
`("skipped", detail)`. Both ask `_refuses_replacement` and both write through
`_write_playlist`, so the rule and the write each exist once. The second must
not `sys.exit`: it runs once per user, and SystemExit would tear down a run
that still had other users to serve - the same reason `UserAccessError` is an
ordinary exception. `require_items` holds the empty check, asked once by
whichever caller assembled the list rather than once per user.

Every creating command takes `-o/--overwrite`. A name collision without it
exits reporting the existing ratingKey and making clear nothing was created or
removed, and that check happens **before** the preview so a doomed run fails
immediately rather than after a screen of output.

`copy_playlist_to` returns `(status, final_name, detail)` and must **not**
delete: `_resolve_dest_name` only reports a replacement when `--overwrite` was
given, so `finalize_playlist` doing it too would be the same logic twice.
Without `--overwrite`, a taken name falls back to appending `" admin copy"`;
if that is taken too the target is skipped with a warning and nothing is
touched.

## COMMANDS

### Listing and searching

- `list-libraries` - columns `id`, `type`, `title`; writes the libraries cache.
- `list-titles LIBRARY [--album A]` - registered with
  `aliases=["list-library"]`. argparse reports whichever spelling was typed,
  so `COMMANDS` needs an entry for both, giving 30 registry entries for 29
  commands; the smoke test floor must allow for it. Table columns are
  `ratingKey`, `title`, `releaseDate`, `rating`, `studio`; a machine-readable
  format carries every listed field and, for a show library, nests each show's
  episodes under a `seasons` object keyed by season name. Always cache the
  minimal `ratingKey`/`title` rows whatever the caller asked to see.
- `list-show RATINGKEY [--m3u P] [-p PREFIX]` - fails fast if not a `Show`.
- `list-users` - `id`, `type`, `title`, `username`, `email`. `account_type`
  returns managed/home/friend/shared. plexapi exposes `restricted` as the raw
  XML **string**, so `"0"` is truthy and a naive check labels every account
  managed.
- `list-playlists USER`, `list-playlist USER PLAYLIST [--m3u P] [-p PREFIX]`.
- `show-metadata RATINGKEY` - type-specific builders via `_METADATA_BUILDERS`
  keyed on `item.type`, plus the file paths. A table names a lone file `file`
  and numbers several `file 1`, `file 2`; other formats carry a `files` list.
- `search USER QUERY [--media-type T] [--library-id L]` - per-library failures
  log a warning and continue.
- `find-missing SHOW [-l LIBRARY] [-A] [-s SEASONS] [--include-specials]` -
  names one show by default, resolved through `resolve_identifier`. `-A`
  sweeps every show and refuses a SHOW argument; `-s` takes one number or a
  comma list and applies only to a single show. For each season the run starts
  at 0 when an episode 0 exists and otherwise at 1, ending at the highest
  present. A season that simply **stops early is not a gap**: an unaired
  episode cannot be told from a missing one. Season 0 is skipped by default
  because specials are numbered irregularly, but `-s 0` overrides that, since
  asking for season 0 can only mean the specials. With nothing missing a
  table prints `No gaps found.`, but a machine-readable format prints an
  empty list: prose on stdout breaks whatever parses it.

### Building and copying

- `build-interleaved NAME RATINGKEY...` - round-robin across shows.
- `build-chronological NAME RATINGKEY...` - sorted by air date. A missing date
  is estimated from neighbours in the same season: collect up to 6 either
  side, require at least 3 known dates (so at least 2 intervals), take the
  median interval, estimate from latest-previous `+median` and earliest-next
  `-median`, averaging when both exist, and prompt only when that is
  impossible. A film with no date has no neighbours, so it is prompted for
  directly. `prompt_for_date` takes **any** item: name an episode by show,
  season, and episode, with `??` for a number Plex lacks rather than `00`,
  which would read as a real episode 0, and anything else by title and year.
  It once read episode-only attributes and crashed on every undated film.
  Running out of input is a `sys.exit` naming the item, not an `EOFError`.
- `build-randomize USER SOURCE DEST` - Fisher-Yates via `secrets.randbelow`.
- `build-concatenated USER NAME PLAYLIST... [--unique]` - joins the sources
  end to end, keeping the order within each and the order they were named in.
  Read **every** source before writing anything, so naming a source as the
  destination works under `--overwrite`: the items are already in memory when
  the old playlist goes. Duplicates are kept by default - a concatenation is
  faithful, and Plex permits a repeat - while `--unique` keeps only an item's
  first appearance, matched on ratingKey rather than title, and reports how
  many it skipped. Warn about an empty source rather than passing over it: a
  typo that resolved to the wrong playlist looks exactly like a playlist that
  happens to be empty.
- `copy-playlist-all-users USER PLAYLIST` - skips the source user, prints the
  item list **once** before the loop and passes `preview=False` thereafter,
  then one line per user (`created`, `replaced`, `skipped`, `failed`) printed
  as it completes. Because the caller surfaces the skip reason, the message
  inside `copy_playlist_to` is `LOG.info`, not a warning that would duplicate
  every skip on stderr.
- `copy-playlist-to-user USER PLAYLIST USER DEST` - a table shows the item
  list then the outcome line; a machine-readable format shows the outcome
  record **alone**, as `copy-playlist-all-users` does, because two documents
  on one stream parse as neither.
- `append-playlist USER PLAYLIST RATINGKEY...`, `remove-playlist USER PLAYLIST`.
- `clean-playlist USER PLAYLIST [--include-partial]` - removes the watched
  entries and leaves the rest in their existing order. Preview first, then one
  `removeItems` call per entry so the loop is paced like any other per-item
  loop; the playlist item IDs come from the listing plexapi already holds, so
  the repeated calls cost nothing beyond the one DELETE each. Number the
  preview by each entry's place in the playlist **as it stands**, not within
  the removal list, so it lines up with what Plex shows. A part-played item is
  **kept** by default - it is the one being watched right now, not a watched
  one - and `--include-partial` drops it, labelled `partial` rather than
  `played`. Refuse a smart playlist: its contents come from a filter, and Plex
  answers a delete on one of its entries with a 400. Warn when every entry is
  going, since Plex drops a playlist that loses its last item.

#### Who a built playlist is for

All four build commands take `-u/--user` and `-a/--all-users`, registered
through `fanout.add_user_target_arguments` as a mutually exclusive group -
writing the pair out on each parser is what trips `duplicate-code`. Without
either, the playlist goes where it always did: the account the items came
from, which is the admin server for the two that take rating keys and the
source user's server for the two that take playlists. `deliver_playlist` takes
both servers for exactly that reason. `-a` targets `accounts.user_roster`,
which **includes the admin** at id 0, because the admin is a user of the
server too.

`fanout.run_per_user` holds the loop: print the item list once, then run a
per-user action and print its one-line outcome as it completes, so a long run
shows progress; in a machine-readable format emit one record per user instead,
since one document cannot carry both. `copy-playlist-all-users` uses the same
loop with its own action and its own target list - the accounts a playlist is
shared with rather than the roster - so the loop is not written twice.

The action must turn every failure into a record rather than raising:
`UserAccessError` becomes a `skipped` carrying its one-line `summary`, a
refused name becomes a `skipped`, and anything else becomes a `failed` with a
`# pylint: disable=broad-except`. One bad user must never end a run that has
others left.

### Watched state

`copy-watched USER USER [-1] [-l LIBRARY] [-t KEY] [--unwatch]`.
`_select_winner(first, second, unwatch)` returns `(winner, loser)` or None:

| situation | default | `--unwatch` |
| --- | --- | --- |
| neither has data, or states match | skip | skip |
| exactly one has data | the one **with** it wins | the one **without** it wins |
| both have data | **latest** `lastViewedAt` | **earliest** `lastViewedAt` |

A `lastViewedAt` of `None` must never win: map it to `datetime.min` when the
latest wins and `datetime.max` when the earliest does. Plex rewrites
`viewOffset` continuously during playback, so treat offsets within
`_OFFSET_TOLERANCE_MS` (10s) as identical, or every run reports spurious
changes. Two fully played states need no sync at all.

plexapi renamed these methods, so call through a helper trying both spellings
(`markPlayed`/`markWatched`, `markUnplayed`/`markUnwatched`) and read played
state from `isPlayed`, then `isWatched`, then `viewCount`. That read and the
resume-point read are `titles.item_is_played` and `titles.item_view_offset`,
because `clean-playlist` needs the same two and the rules must not be written
twice. All three attributes default to a bool or a number rather than None, so
reading them does not satisfy plexapi's reload condition.

Watch state lives on leaf items: map `movie`->`movie`, `show`->`episode`,
`artist`->`track`, and skip photo libraries. Use `section.all(libtype=...)`,
**not** `section.search(...)`, which needs a non-empty query.

### Exporting

- `export-playlist USER PLAYLIST PATH`, `export-titles LIBRARY PATH
  [--sort alpha|date|random] [--album A]`.
- M3U carries Plex server filesystem paths, `#EXTINF` seconds from
  `duration`/1000 with `-1` fallback, and skips items with no path.
- Photo libraries export a self-contained Spotlight.js gallery. **No Plex HTTP
  URLs anywhere in it**: both `href` and `src` are server filesystem paths.
- `-p/--prefix` on all eight export commands, registered through
  `paths.add_prefix_argument` - repeating the help text inline trips
  `duplicate-code`. `mapper_for(plex, args)` returns `identity` without a
  prefix, so the default costs no extra call. The prefix replaces the
  **library root**, so `library_roots()` sorts longest-first or a library
  nested in another's tree matches the wrong one. Rejoin using the prefix's
  own separator style. A path under no known root is appended whole and warned
  about exactly once.

### Photos

`collect_photos` walks `section.all()` (albums) then `palbum.photos()`.
**Never** `section.search(libtype="photo")`: Plex requires a non-empty query
string and returns nothing for an empty one, so the library appears empty.

### Streaming and server management

- `read LIBRARY RATINGKEY` - streams to `sys.stdout.buffer` in 64KB chunks via
  `requests.get(stream=True)`. Validate the item's `librarySectionID` matches.
  Catch `BrokenPipeError` silently and `os.dup2` the null device onto stdout,
  so the interpreter's final flush does not print "BrokenPipeError ignored"
  when the user quits the player. The epilog shows worked examples including
  `plexdo read 3 12345 | mpv --vo=drm -` for **Linux with a display attached
  but no desktop environment** - describe the condition, not the machine:
  "headless" contradicts having a screen, and the hardware could be a Pi, a
  kiosk, a sign, or an old laptop.
- `rescan [LIBRARY] [-s] [-n]` - `plex.activities` is a **property**, not a
  method. `section.update()` scans for files; `refresh()` only re-fetches
  metadata.
- `status [--section S]` - the sessions table carries `sessionKey`, `user`,
  `library`, `ratingKey`, `title`, `state`, `player`, `platform`, `address`,
  `progress`, and `file`; `sessionKey` is what `wait --session` takes. `records.playing_file` reports the file in use: a session marks
  the version and part being played with `selected`, which is the only way to
  tell them apart on a multi-version item, and nothing is marked when there is
  just one, so an empty selection means all of them. Eight sections: `server`, `sessions`, `users`,
  `accounts`, `connections`, `scans`, `activities`, `tasks`. Split
  `plex.activities` on whether the type mentions scan/refresh/library so scans
  and other background work are separate. Collect each section in its own
  try/except: `connections` needs a plex.tv round trip an offline server
  cannot make. CSV and CLIXML are flat and require `--section`.

- `wait [-s KEY] [-r KEY] [-a] [-i S] [--fast-interval S] [--no-credits]
  [--offset S] [--paused S] [--now]` - sleeps until a play is over, then
  exits 0, so
  `plexdo wait && ...` runs afterwards. Follows the first session listed, or
  `-s`, and waits for what it is playing, or `-r`.
  - **The end point** is the start of the end credits when there is a credits
    marker and the video's end otherwise; `--no-credits` always uses the end.
    Take the marker Plex flags `final`, else the last: a film with a
    mid-credits scene has two, and the final one follows the scene. Read the
    markers from `_data`, as `records` reads media, after fetching with
    `fetch_item(..., markers=True)` - a plain fetch omits them, and touching
    `markers` afterwards costs a reload.
  - **The wait also ends** the moment the video stops playing, wherever it had
    got to: the session is gone, or has moved to another ratingKey because
    autoplay went on to the next episode.
  - **`--paused S`** counts a play as over once its player has sat paused S
    seconds. Plex gives no pause timestamp, so the count starts at the first
    poll that sees the pause, keyed by play - (sessionKey, ratingKey) - and
    any other state clears it. Compare rounded to the millisecond: a sleep
    timed to land on the deadline must count as reaching it despite float
    error. While paused, the estimate is the pause's time left.
  - **One judge:** `_assess` decides every play's state - reached, left
    paused, or the player's own - for both a single wait and `--all`, so the
    two cannot drift apart.
  - **`--all`** judges every session on its own video each poll, one line or
    record per session, reporting a play that has vanished once as
    `stopped`. Nothing pending must be seen **twice, a slow interval apart**,
    before it exits - the second poll is what catches a play started in
    between, autoplay's next episode included - and that holds even when
    nothing was playing to begin with. Fetch each video's markers once per
    ratingKey with `find_item`, which returns None rather than exiting:
    an item the server will not return falls back to the session's own
    duration and XML instead of ending a wait over everyone. Refuses `-s`
    and `-r`.
  - **Offset:** negative moves the end point that many seconds earlier,
    clamped at 0; positive is a sleep taken *after* the wait, not a position,
    since there is no playback left to watch past the end.
  - **Cadence:** `-i` (10s) until any unfinished play is within 30s of its
    end point or past it, then `--fast-interval` (1s); every delay jittered
    by up to 20% either way. A slow delay is cut short at the soonest moment
    any play next needs a look - its fast window, or its pause running out -
    so a long `-i` cannot sleep straight through either. Not `paced`: this is a
    timed poll, not a loop over elements.
  - **Following:** bind to the user of the session being followed, preferring
    its original sessionKey but accepting any of that user's sessions on the
    video, since stopping and restarting gives the same play a new key. `-r`
    without `-s` binds nobody until someone is seen playing the video; until
    then a poll describes the first session listed.
  - **Every poll** reports the user, what is playing, the video waited for,
    position, time waited, and estimated time left - one line for a table,
    one streamed record otherwise, each record carrying every key so CSV
    columns stay fixed. A video not yet playing says so and is waited for;
    `--now`, valid only with `-r`, exits 0 instead.
  - **Failure:** a failed poll is a warning and a retry, since a wait can last
    hours; five in a row is a `sys.exit`. Nothing playing at all is exit 0.
    An unknown `-s` exits 1 listing the active sessionKeys - a typo must not
    let `&&` run. Ctrl-C exits 130 for the same reason.
  - Time, sleep and jitter come in as one `Clock`, so the loop is tested
    without waiting.
- `player-remote-play HOST RATINGKEY [--port P] [--player-user USER]
  [--server-url URL]` -
  connects to the player itself with plexapi's `PlexClient` and calls
  `playMedia`, not proxied through the server, so the server need not know
  the player.
  - **IP only on the wire.** Some players refuse a Host header carrying a
    name, as a DNS rebinding defence, so resolve a name here with
    `getaddrinfo` and build every URL from the address; bracket IPv6. Try
    each address in the resolver's order until a player answers at
    `/resources`. Something answering without a `machineIdentifier`, or
    with a reply plexapi cannot parse, is not a player and the next
    address or port is tried; none answering is a `sys.exit` naming each
    URL, and suggesting `--port` when it was not given.
  - **The player's port.** Plex apps differ: 32500 Android/Fire TV, 8324
    Roku, 32433 Plex HTPC/Plex Media Player, 3005 Plex Home Theater,
    Plexamp anywhere. `--port` has no default and, given, is used as is.
    Without it, send a unicast GDM `M-SEARCH * HTTP/1.0` to UDP 32412 at
    each IPv4 address (2 s); a `200 OK` reply from that address whose
    `Content-Type` (if any) names a player gives the port to use. Silence,
    an IPv6 address, or an unusable reply falls back to `_KNOWN_PORTS` in
    the order above, each first checked with a 2 s TCP connect so dropped
    guesses do not each cost the 10 s probe timeout. Tests stub GDM and
    the connect check by default, and exercise both for real on loopback.
  - **Probe without a token**: what answers may not be the player, and a
    token is a credential. Only a 401 earns a retry with plexdo's token.
  - **Whom to send as.** A player trusts the account signed in to it. Send
    as plexdo's own account when it is the server's admin
    (`plex.myPlexUsername`, an email, matching its email or username,
    ignoring case) or when the player's machine identifier is among its
    plex.tv `devices()`; otherwise as the first `configured_accounts()`
    entry whose devices include the player; otherwise as plexdo's own
    anyway, with a warning naming the `users` entry fix. plex.tv being
    unreachable for any of this is a warning and plexdo's own token, never
    the end of the command.
  - **The sending account also fetches the item.** `playMedia` builds the
    play queue and mints the delegation token on `media._server`, and the
    player switches its user to whoever that is, so an item fetched as
    plexdo makes a Roku signed in as a managed user switch to plexdo's
    user. `_server_as` tries `connect_with_token` with the account's own
    token, then, since a server refuses a managed user's plex.tv token,
    `connect_as_shared_user` with the matching `current.users()` entry
    (the admin-minted server token every per-user command uses). Neither
    working is a `sys.exit` for `--player-user` (strict) and a warning
    saying the player will switch, then plexdo's server, for the
    automatic choice.
  - **`--player-user`** names the account instead - a plex.tv id, username,
    or email, ignoring case - and overrides every rule above, the admin
    shortcut included. plexdo's own account when it matches, or when the
    value is `0` and plexdo is the admin, since `0` means the admin
    everywhere else. Then a Plex Home member (`home` true in
    `current.users()`, managed users included) matching by id, username,
    or email, or by title when it has no username, as a managed user has
    none: `switchHomeUser(user, pin=)` gives its plex.tv account without a
    password, `pin` being the one `set-pin` saved; the switched token is not
    stored, as switching is cheap and needs no secret. A refused switch
    falls through to the `users` entries, and with none is a `sys.exit`
    naming `plexdo set-pin <id>`. Otherwise a
    `configured_entries()` entry whose id or
    username is the value is taken at its word without a sign-in to check;
    failing that, sign the rest in one at a time through
    `configured_account` and compare id, username, and email, because an
    entry may file the account under the email when given the username.
    Nothing to act as, or plex.tv unreachable, is a `sys.exit`: never send as
    some other account than the one asked for. Deliberately not in
    `USER_ID_ARGUMENTS`: the value is matched against accounts, not the
    roster, whose titles are display names rather than usernames.
  - **The fetch address** is `playback.fetch_address`, below.
  - A 401 from the player is a `sys.exit` explaining the account rule. A
    `ParseError` is success: plexapi parses only after checking the status,
    and some players answer a command with a bare `OK`.
  - `--dry-run` resolves, probes, chooses the account and the address, and
    sends nothing; choosing may still sign in a configured account. Output
    carries `player`, `machineIdentifier`, `product`, `host`,
    `playerAddress` (the URL used), `account`, `ratingKey`, `title`,
    `fetchFrom`, `status`.
  - Every library item plexapi returns carries a `listType`, or a playlist a
    `playlistType`, so `playMedia` accepts all of them; no type check is
    needed, and one would be dead code.

#### The address a player fetches from

`playMedia` tells the player to fetch from the media's server URL, plexdo's
configured one, which suits plexdo's network and not necessarily the
player's: plexdo often runs on another routed segment, or another IPv6 site.
`playback.fetch_address` chooses instead, and the command passes protocol,
address, and port to `playMedia`, whose `**params` override its own:

- Candidates: the configured URL, listed first, and every connection plex.tv
  lists for the server except relays, which are throttled below what video
  needs. Drop any whose address is loopback, link-local, unspecified, or
  multicast - for a plex.tv connection, test its bare `address`, not just
  its URI's host, which is a plex.direct name that says nothing.
- Name a plex.tv candidate by its URI's host, normally a plex.direct name
  over https, and rank it by its bare address: https to a bare IP fails the
  certificate.
- Rank against the player's IP, an IPv6-mapped IPv4 address read as IPv4:
  same family; then same scope by `ipaddress.is_global`, so RFC 1918, ULA,
  and carrier-grade NAT are private alike; then the longest shared prefix. A
  host name, family unknown, ranks between a family match and a mismatch.
  Scope outranks prefix: bits shared across the private/global line mean
  nothing. A tie keeps the first, the configured URL.
- Bracket an IPv6 literal sent as the address: the player joins address and
  port into a URL.
- Do not probe the chosen address. A probe runs from plexdo's network, which
  is the very network that may differ from the player's.
- `--server-url` bypasses all of it, loopback included, for a player on the
  server's own machine or networks joined by NAT; it must be an http or
  https URL with a host, port defaulting to 32400.
- With no candidate left, `sys.exit` asking for `--server-url` or a
  reachable `plex: url`. Show the address chosen in the output
  (`fetchFrom`).
- Tests must use genuinely global addresses for anything about scope: the
  documentation ranges, 2001:db8::/32 and 192.0.2.0/24 among them, are not
  global to `ipaddress`, and would leave the rule unexercised.

The ratingKey argument, `--server-url`, and the record's closing fields come
from `playback.add_play_arguments` and `play_fields`, so a second playback
command would not repeat them and trip `duplicate-code`.

### Setup

- `login [-u USER] [-p PASS] [--save-password] [-c CODE] [-2]` - the
  username is `--username`, else `plex: username`, else prompted for; the
  password is `--password`, else the one saved for that username, else read
  with `getpass`. The token is saved under the username (`ADMIN_KEY` with
  none), and `--save-password` saves the password beside it so plexdo can
  sign in again by itself once the token stops working. `--dry-run`
  authenticates and saves nothing. The table says where the token went
  (`secret_store.describe()`); other formats give `user`, `store`,
  `verified`, and `passwordSaved`. `--password` on the command line is
  scrubbed from the process title via `ctypes.pythonapi.Py_GetArgcArgv`
  (`sys.argv` is only a copy; `ps` reads the original buffer) and redacted
  from `sys.argv`, with a warning that is blunt about what cannot be fixed:
  the value is already in shell history and was visible during interpreter
  startup. On Windows the rewrite has no equivalent.
- `logout [-u USERNAME]` - signs each stored token out at plex.tv
  (`MyPlexAccount(token=...).signout()`) and removes it from the store via
  `tokens.forget_tokens`, **and removes the saved password** of each account
  signed out, which would otherwise sign it straight back in on the next
  command. `-u` names a store key, exactly then ignoring case; one the store
  lacks is a `sys.exit` listing those it has, since signing out nobody would
  read as success. A token plex.tv already refuses (`Unauthorized` on
  loading the account) is `already signed out` and removed; any other
  failure leaves the token valid, so it is **kept** for a retry and the run
  exits 1 after removing the rest. Sign a token filed under two names out
  once. `--dry-run` contacts nothing. Needs no server, so it works when the
  server is down.
- `set-pin USER_ID [--remove]` - prompts with `getpass` for a Plex Home
  member's PIN, four digits, an empty answer meaning none, and saves it
  under the numeric user id; `--remove` removes it without prompting. The
  id is not resolved against the roster, so no server is needed.
  `--dry-run` saves nothing.
- `write-config-example [-o]` - writes `CONFIG_EXAMPLE`, mode 0600. An
  existing config is refused without `-o/--overwrite`, and that check comes
  before `--dry-run`, so a rehearsal fails where the real run would; a
  dry run then writes nothing and says where it would write. Its `--help`
  prints the exact template, which needs `RawDescriptionHelpFormatter` for the
  epilog's newlines; that same formatter stops argparse wrapping the
  description, so pre-wrap it with `textwrap.fill(..., width=78)`.

## ERROR HANDLING

`sys.exit` with a clear message for: missing or malformed config, missing
token, an unusable keyring, unknown
ratingKey, wrong media type, playlist or user or library not found,
unsupported library type, album not found, a refused name collision, a smart
playlist asked to give up an entry, an unknown sessionKey, an existing config
file without `--overwrite`, an air date with no input to answer for it, a
`logout -u` name not in the secret store, a player host that does not resolve
or where no player answers. A
command line argparse cannot parse exits 2, its own convention. An
interrupted `wait` exits 130. No bare
`except`; the broad ones are in `_cancel_all_scans`, the per-user copy loop,
and per-section collection, each with a `# pylint: disable=broad-except`.

## PLATFORM SUPPORT

- **macOS** ships bash 3.2, which has no `mapfile`; fill `COMPREPLY` with a
  `while IFS= read -r` loop, `_plexdo_compreply`, fed by process
  substitution: `COMPREPLY=(); _plexdo_compreply < <(compgen ...)`. That
  is also what ShellCheck's SC2207 asks for in place of
  `COMPREPLY=( $(compgen ...) )`, which splits a file name with a space in
  it into two candidates. Use `stat -c %Y` with a `stat -f %m` fallback.
  Restrict the Makefile to `install -d` / `install -m`; `install -D` is
  GNU-only.
- **Windows**: skip the POSIX permission check and `chmod`; the console
  encoding fallback above; `--password` scrubbing is best-effort only.
- **Secrets**: `keyring` picks the platform's store itself; on Linux and BSD
  it needs a Secret Service provider such as GNOME Keyring or KWallet on the
  session bus. A headless machine has none, so `auto` falls back to the
  file there.
- All completions fall back to `python` when `python3` is absent, as under Git
  Bash.

## SHELL COMPLETION

Four scripts in `completions/`, mirrored into `src/plexdo/data/` as package
data. All cover the same values, read a 15-minute cache under the platform
cache directory (honouring `cache_dir`), and refresh a stale cache in the
background so completion never blocks.

Numeric IDs are shown with their titles. Distinguish what is *insertable* from
what is merely *shown*: user and library titles are insertable because those
arguments accept them, but an item title is not, since `fetchItem` needs a
numeric ratingKey.

- **bash** - no `bash-completion` package: initialise from `COMP_WORDS` and
  `COMP_CWORD`, use `compgen -f` not `_filedir`. Values that can contain
  spaces must reach `COMPREPLY` as whole lines via the read-loop helper, not
  through `compgen -W`, which splits `TV Shows` into two candidates. bash
  cannot attach descriptions, but it only *inserts* once a single candidate
  remains, so `_plexdo_compreply_pairs` lists annotated forms while a choice
  remains and substitutes the bare value when it collapses to one.
  `_plexdo_complete_rating_key` merges keys from every `titles.*.json`, so it
  must gather then render once and clear `COMPREPLY` on entry.
- **zsh** - `#compdef plexdo`, `_arguments -C` with `'*::arg:->subcmd'`. Note
  that under `*::` zsh **rebinds `words` so `words[1]` is the subcommand**.
  Sanitise `:` out of descriptions, since `_describe` splits on it.
- **fish** - no positional-index primitive, so walk `(commandline -opc)`,
  skipping options that consume a value. Guard the background refresh on the
  binary existing: fish reports an unknown command *before* redirection
  applies, scribbling on the prompt mid-completion.
- **PowerShell** - `Register-ArgumentCompleter -Native`. Needs no external
  interpreter: `ConvertFrom-Json` reads the cache. Return `CompletionResult`
  objects so the ID is inserted and the title shows as a tooltip. Two traps
  that parse cleanly and fail at runtime: a range of `0..-1` **wraps** and
  yields the whole array, and a parenthesised `if` is not a valid argument
  expression.

## MAN PAGE

`man/plexdo.1`, in roff, mirrored into `src/plexdo/data/`. Documents every
command, the configuration and token formats, exit status, environment, files,
and worked examples, and passes `groff -man -Tutf8 -ww -z` with no warnings.

Escapes that silently corrupt copy-pasteable examples: a literal backslash is
`\e`, a literal apostrophe is `\(aq`, and a backtick is `\(ga`. groff remaps a
bare `'` to a typographic right quote on many configurations. Double quotes
are not remapped. Write URLs as plain text with `.I`, not with `.UR`/`.UE`,
which emit OSC-8 escapes that duplicate the URL wherever the page is piped
through something that does not understand them.

## TESTS

`tests/` holds a pytest suite that must pass in `make check`. It covers the
project's own decision logic and never touches a network; `conftest.py`
provides one `FakeItem`, one `FakeMedia`, and friends, and an autouse fixture
giving every test an empty in-memory secret store, so no test reads or
writes the keyring or secrets file of whoever runs it, and none fails on CI
for want of one. Likewise no test may read the developer's own
`config.yaml`: a test needing one writes it under `tmp_path`. Do not define a second
mock for a concept that already has one.

Worth testing, because a regression there is silent rather than loud: the four
serialisers round-tripping through real parsers; table alignment under
double-width characters, the ASCII fallback, and fitting to a budget;
identifier resolution including the ID-versus-title collision; the overwrite
guard performing **no** server calls when it refuses; watched-state selection
in both directions and that an undated state never wins; which playlist entries
`clean-playlist` drops, and that it numbers them by their place in the
playlist rather than within the removal list; that concatenation preserves the
order given and that `--unique` matches on ratingKey; that a per-user create
reports a taken name instead of exiting and performs no call when it does, and
that a fan-out records an unreachable user and an unexpected failure and keeps
going; where `wait` puts its end point for every combination of marker,
`--no-credits` and offset, that it switches to fast polls 30s out and never
sleeps through that window, that it ends on a stop or on autoplay moving on,
that a failed poll is retried; that `--paused` ends a play on its deadline and
a resume restarts the count; and that `--all` waits for a play started during
its confirming poll, takes that second look even when nothing played, and
judges an unfetchable video by its session; that `logout` keeps a token it
could not sign out and removes one plex.tv already refuses; that
`player-remote-play` sends only IPs, probes without a token, and picks its
account by the rules above; that the fetch address follows family, then
scope, then prefix; path rewriting
picking the longest matching root; that the template is valid YAML stating
every option and its default; that the config refuses a secret and reports
an unknown option by name; that `auto` falls back to the file only without a
real keyring, and `file` never asks one; that the secrets file is private
from the moment it exists and goes with its last secret; that the 1.x
migration moves every token, password, and PIN, a legacy bare-token file
included, leaves no secret in plaintext, and keeps a secret already saved
over the old one; that `login --save-password` spares the next prompt;
that `logout` removes a signed-out account's saved password; that `set-pin`
takes four digits only; that `loaded_fields` and `file_paths` never trigger a reload; and that
global flags survive being given before the subcommand.

Each bug that shipped gets a regression test in `tests/test_fixes.py` that
**fails against the code before the fix**; check that it does, or it proves
nothing. Build real plexapi `Movie` and `Episode` objects from XML for these
where the bug was about their attributes - they construct offline - rather
than stand-ins that would have hidden it.

Two behaviours easy to get backwards: two fully played states need no sync, so
a "latest wins" test must use states that actually differ; and
`format_duration(None)` is empty while `format_duration(0)` is `"0:00"`,
because unknown and zero are different things.

## BUILD

`make check` runs, in order: `check-version`, `check-assets`, `smoke`, `test`,
`lint`, `build`, `dist-check`.

- `check-version` runs `tools/derive.py --check`: the man page's `.TH` line
  and the SBOM must match `pyproject.toml`. `make derive` rewrites them.
- `lint` runs `ruff check`, `ruff format --check`, pylint, ShellCheck,
  yamllint (the repository's YAML, and the template plexdo writes, piped
  in), markdownlint-cli2 through `npx --no-install` from `package.json`, and
  `reuse lint`. A tool that is not installed fails the target, naming it.
- `check-assets` verifies the four completions and the man page match their
  packaged mirrors, and that no source file contains non-ASCII. Editing the
  source copy and forgetting the mirror ships a stale asset in the wheel.
- `smoke` imports the package, builds the full parser, and asserts every
  module in `MODULES` still exposes `register()` and `COMMANDS`. pylint scores
  a module 10.00/10 even when an edit has truncated that tail, which breaks
  every command in it.

The Makefile also provides `install`, `uninstall`, `develop`, `reinstall`,
`install-man`, `uninstall-man`, `install-completion` and its four per-shell
targets, `clean`, and `distclean`, all `.PHONY`, with `help` as the default
target printing the paths in effect. Completion destinations default to the
XDG user locations, switch to `$(PREFIX)/share/...` when `PREFIX` is set, and
honour `DESTDIR`. `SHELLS` defaults to `bash zsh fish`; powershell is opt-in.
`uninstall-completion` tests for each exact file rather than removing a
directory, and `uninstall` prefixes pip with `-` so an already-absent package
still cleans up.

CI runs `make check` on Python 3.11 through 3.14, installing ShellCheck and
yamllint from apt, `reuse` and the Python tools from `.[dev]`, Node from
`.nvmrc` with `actions/setup-node`, and markdownlint-cli2 with `npm ci`,
plus jobs that parse all four completions and lint the man page.
`publish.yml` runs the same `make check` before building a release, so it
installs exactly the same tools: a linter added to `make check` is added to
both workflows' install steps, or the next release fails to publish.

**Every workflow declares `permissions`.** Without one, its jobs receive
whatever the repository default grants, which CodeQL flags as
`actions/missing-workflow-permissions` once per job. `contents: read` at the
top of the file is enough for anything that only builds and tests; a job
needing more raises it for itself, as the publish jobs do with
`id-token: write`.

Keep actions on their newest major: GitHub retires the Node runtime beneath
them periodically, so an old major first warns and eventually stops running.
Check `runs.using` in the action's own `action.yml` rather than trusting a
version number, and confirm the inputs still exist before jumping several
majors at once.

`publish.yml` uploads to PyPI through Trusted Publishing (OIDC), so no API
token exists to leak. It builds and verifies in one job, then publishes from a
second that raises only `id-token: write` and names a `pypi` environment,
which is where a deployment protection rule can be attached. Publishing runs
on a published GitHub release; `workflow_dispatch` offers TestPyPI for a
rehearsal, with `skip-existing` so a repeated dry run does not fail.

The release job must confirm the tag matches the packaged version. PyPI
uploads are immutable, so publishing `v1.2.3` from a tree carrying some other
version cannot be undone. Strip a leading `v` before comparing.

## COMMAND PAGES

Beside the roff overview, every command has a detailed page in scdoc source
format: `man/plexdo-COMMAND.1.scd`, one per command, with `list-library`
documented in the `list-titles` page as the alias it is, and
`man/plexdo-conventions.7.scd` for what every command shares - the global
options, how users, libraries, playlists, items, and sessions are named,
output formats, path rewriting, pacing, exit status, files, and
environment. Nothing installs them; `MANIFEST.in` ships them in the sdist,
and they sit under `man/`, so `check-assets` holds them to plain ASCII.

They are also the project wiki, one page each. `tools/wiki-pages.sh`
converts them scdoc -> roff -> `pandoc --from man --to gfm`, and `make wiki`
runs it into `build/wiki/`. mandoc cannot do this step: its Markdown output
takes mdoc(7) only, and scdoc writes man(7). The script:
- names the page for the file less its section, so `plexdo-wait.1.scd` is
  the page `plexdo-wait`, and fails if two sources would share a name;
- writes each stage to a file of its own rather than a pipe, since a plain
  POSIX pipe hides a failure upstream and yields a truncated page;
- rewrites `**plexdo-X**(N)` into a link to page `plexdo-X`, and fails when
  a link names a page that was not generated: a dangling reference is a
  build error, not a broken link on the wiki;
- opens each page with a hidden `<!-- Generated from ... -->` comment.

`.github/workflows/wiki.yml` builds the pages in a `contents: read` job, on
every push and pull request touching them, and uploads them as an artifact.
A second job, only for `main` and never for a pull request, raises
`contents: write` - the permission under which `GITHUB_TOKEN` pushes to the
wiki - checks out `REPO.wiki`, and syncs: it deletes a page only when its
**first line** is the generated mark and its source is gone, so a page
written on the wiki by hand is never deleted, then copies the rest in and
commits only when something changed. Check that the wiki repository exists
first with `git ls-remote`: GitHub creates it only once a first page has
been made by hand, and checkout's bare "repository not found" does not say
so. The workflow is in a `wiki` concurrency group, queued rather than
cancelled, since two pushes to the wiki at once would race.

Each command page has, in order: NAME, SYNOPSIS, DESCRIPTION, ARGUMENTS,
OPTIONS, GLOBAL OPTIONS, OPTION INTERACTIONS, OUTPUT, EXIT STATUS, EXAMPLES,
and SEE ALSO. GLOBAL OPTIONS says what **each** global flag does for that
particular command - "no effect" is an answer, and `--dry-run` writing a
file anyway is worth saying. OPTION INTERACTIONS covers every conflict and
dependency, whether argparse enforces it (`-u`/`-a`) or the handler does
(`find-missing -A` with SHOW, `wait --now` without `-r`). Document what the
code does, checked against it, not what a help string implies. Sample tables
are real output from `print_table` with an ASCII console, never hand-drawn.

scdoc accepts several mistakes silently, so follow these rules:
- **Write every flag in prose bold**, `*--flag*`. A line starting with a
  bare `-` is a scdoc error, and wrapping puts flags at line starts.
- **No spaced ` - ` dash in prose.** Wrapped to a line start it silently
  becomes a bullet. The NAME line's `name - summary` is the one exception.
- **Escape a literal `*` as `\*`.** An unclosed one silently eats text.
- **Never start a line with `[`, `|`, or `:`.** scdoc reads it as a table
  row anywhere, not only after a blank line; escape it as `\[`, which
  wrapped SYNOPSIS lines need. A line starting `; ` silently vanishes as a
  comment, and one starting `. ` is a numbered list.
- **A word-initial `_` underlines.** Mid-word underscores are literal.
- Indent with TABs only, and double every backslash, inside literal blocks
  too: a Windows path `\\NAS\media` is written `\\\\NAS\\media`.
- Wrap at 80 columns, counting a TAB as eight.

Validate by compiling every page with scdoc and checking the roff with
`groff -man -ww -z`, and check that every option string of every command
appears bold in its page.

## DELIVERABLES

1. The `plexdo` package under `src/`, laid out as above
2. `pyproject.toml`, `Makefile`, `MANIFEST.in`, `requirements.txt`,
   `.gitignore`, `.gitattributes`, `REUSE.toml`, `package.json`,
   `package-lock.json`, `.markdownlint-cli2.yaml`, `.yamllint.yaml`
3. `README.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, `LICENSE`,
   `LICENSES/GPL-3.0-or-later.txt`, and `sbom.spdx.json` from
   `tools/derive.py`
4. `man/plexdo.1`, and the scdoc command pages beside it
5. `completions/` and its mirror in `src/plexdo/data/`
6. `tests/`
7. `.github/workflows/ci.yml`, `publish.yml`, and `wiki.yml`, with
   `tools/wiki-pages.sh`
8. `AI.prompt.txt` - this file, containing the full prompt that regenerates
   the project, the preamble and its requirement to create and maintain
   this file included

Verification: `make check` passes, `vulture src/plexdo --min-confidence 0` is
empty, and installing the wheel into a clean virtualenv gives a working
`plexdo` console script.

REUSE-IgnoreEnd
