plexdo 2.0.3__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. {plexdo-2.0.3 → plexdo-3.0.0}/AI.prompt.txt +568 -120
  2. {plexdo-2.0.3 → plexdo-3.0.0}/CHANGELOG.md +181 -1
  3. {plexdo-2.0.3 → plexdo-3.0.0}/Makefile +3 -2
  4. {plexdo-2.0.3 → plexdo-3.0.0}/PKG-INFO +225 -91
  5. {plexdo-2.0.3 → plexdo-3.0.0}/README.md +224 -90
  6. {plexdo-2.0.3/src/plexdo/data → plexdo-3.0.0/completions}/_plexdo +192 -112
  7. {plexdo-2.0.3 → plexdo-3.0.0}/completions/plexdo.bash +210 -122
  8. {plexdo-2.0.3/src/plexdo/data → plexdo-3.0.0/completions}/plexdo.fish +180 -92
  9. {plexdo-2.0.3/src/plexdo/data → plexdo-3.0.0/completions}/plexdo.ps1 +148 -83
  10. plexdo-3.0.0/man/plexdo-album-list.1.scd +155 -0
  11. plexdo-3.0.0/man/plexdo-albums-list.1.scd +230 -0
  12. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-conventions.7.scd +149 -29
  13. plexdo-2.0.3/man/plexdo-list-libraries.1.scd → plexdo-3.0.0/man/plexdo-libraries-list.1.scd +14 -8
  14. plexdo-2.0.3/man/plexdo-list-titles.1.scd → plexdo-3.0.0/man/plexdo-library-list.1.scd +27 -20
  15. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-login.1.scd +18 -3
  16. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-logout.1.scd +6 -0
  17. plexdo-3.0.0/man/plexdo-merge-build.1.scd +211 -0
  18. plexdo-2.0.3/man/plexdo-find-missing.1.scd → plexdo-3.0.0/man/plexdo-missing-find.1.scd +17 -11
  19. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-player-remote-play.1.scd +11 -5
  20. plexdo-3.0.0/man/plexdo-playlist-build.1.scd +305 -0
  21. plexdo-2.0.3/man/plexdo-clean-playlist.1.scd → plexdo-3.0.0/man/plexdo-playlist-clean.1.scd +20 -13
  22. plexdo-3.0.0/man/plexdo-playlist-copy.1.scd +212 -0
  23. plexdo-2.0.3/man/plexdo-export-playlist.1.scd → plexdo-3.0.0/man/plexdo-playlist-export.1.scd +17 -11
  24. plexdo-2.0.3/man/plexdo-list-playlist.1.scd → plexdo-3.0.0/man/plexdo-playlist-list.1.scd +17 -11
  25. plexdo-2.0.3/man/plexdo-remove-playlist.1.scd → plexdo-3.0.0/man/plexdo-playlist-remove.1.scd +19 -14
  26. plexdo-2.0.3/man/plexdo-list-playlists.1.scd → plexdo-3.0.0/man/plexdo-playlists-list.1.scd +14 -8
  27. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-read.1.scd +18 -15
  28. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-rescan.1.scd +46 -11
  29. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-search.1.scd +18 -5
  30. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-set-pin.1.scd +7 -2
  31. plexdo-2.0.3/man/plexdo-list-show.1.scd → plexdo-3.0.0/man/plexdo-show-list.1.scd +16 -10
  32. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-show-metadata.1.scd +10 -4
  33. plexdo-2.0.3/man/plexdo-copy-watched.1.scd → plexdo-3.0.0/man/plexdo-show-watched-cp.1.scd +42 -22
  34. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-status.1.scd +8 -2
  35. plexdo-2.0.3/man/plexdo-export-titles.1.scd → plexdo-3.0.0/man/plexdo-titles-export.1.scd +20 -12
  36. plexdo-2.0.3/man/plexdo-list-users.1.scd → plexdo-3.0.0/man/plexdo-users-list.1.scd +21 -10
  37. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-wait.1.scd +6 -0
  38. {plexdo-2.0.3 → plexdo-3.0.0}/man/plexdo-write-config-example.1.scd +8 -2
  39. plexdo-3.0.0/man/plexdo-write-secrets-example.1.scd +180 -0
  40. {plexdo-2.0.3/src/plexdo/data → plexdo-3.0.0/man}/plexdo.1 +351 -200
  41. {plexdo-2.0.3 → plexdo-3.0.0}/pyproject.toml +1 -1
  42. {plexdo-2.0.3 → plexdo-3.0.0}/sbom.spdx.json +76 -76
  43. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/accounts.py +25 -6
  44. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/arguments.py +17 -15
  45. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/cache.py +31 -0
  46. plexdo-3.0.0/src/plexdo/cli.py +362 -0
  47. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/__init__.py +51 -2
  48. plexdo-3.0.0/src/plexdo/commands/albums.py +424 -0
  49. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/auth.py +135 -25
  50. plexdo-3.0.0/src/plexdo/commands/build.py +628 -0
  51. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/copy.py +94 -53
  52. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/libraries.py +19 -22
  53. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/metadata.py +3 -6
  54. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/missing.py +43 -8
  55. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/playlists.py +59 -101
  56. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/remote.py +1 -1
  57. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/rescan.py +56 -27
  58. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/search.py +68 -13
  59. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/status.py +3 -20
  60. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/stream.py +9 -24
  61. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/users.py +7 -14
  62. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/watched.py +87 -26
  63. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/config.py +1 -1
  64. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/connection.py +2 -2
  65. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/constants.py +56 -2
  66. {plexdo-2.0.3/completions → plexdo-3.0.0/src/plexdo/data}/_plexdo +192 -112
  67. {plexdo-2.0.3/man → plexdo-3.0.0/src/plexdo/data}/plexdo.1 +351 -200
  68. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/data/plexdo.bash +210 -122
  69. {plexdo-2.0.3/completions → plexdo-3.0.0/src/plexdo/data}/plexdo.fish +180 -92
  70. {plexdo-2.0.3/completions → plexdo-3.0.0/src/plexdo/data}/plexdo.ps1 +148 -83
  71. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/fanout.py +8 -8
  72. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/formats.py +1 -1
  73. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/identify.py +24 -0
  74. plexdo-3.0.0/src/plexdo/logs.py +92 -0
  75. plexdo-3.0.0/src/plexdo/photos.py +163 -0
  76. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/playback.py +1 -1
  77. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/playlists.py +6 -8
  78. plexdo-3.0.0/src/plexdo/profiling.py +409 -0
  79. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/secret_store.py +159 -14
  80. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/sections.py +5 -5
  81. plexdo-3.0.0/src/plexdo/security.py +173 -0
  82. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/titles.py +16 -0
  83. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo.egg-info/PKG-INFO +225 -91
  84. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo.egg-info/SOURCES.txt +23 -19
  85. {plexdo-2.0.3 → plexdo-3.0.0}/tests/conftest.py +2 -0
  86. plexdo-3.0.0/tests/test_albums.py +560 -0
  87. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_cli_and_config.py +336 -35
  88. plexdo-3.0.0/tests/test_debug_log.py +280 -0
  89. plexdo-3.0.0/tests/test_fixes.py +747 -0
  90. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_identify.py +4 -4
  91. plexdo-3.0.0/tests/test_photos.py +122 -0
  92. plexdo-3.0.0/tests/test_playlists.py +268 -0
  93. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_records_missing.py +1 -1
  94. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_secret_store.py +242 -1
  95. plexdo-3.0.0/tests/test_watched.py +179 -0
  96. {plexdo-2.0.3 → plexdo-3.0.0}/tools/wiki-pages.sh +1 -1
  97. plexdo-2.0.3/man/plexdo-append-playlist.1.scd +0 -118
  98. plexdo-2.0.3/man/plexdo-build-chronological.1.scd +0 -177
  99. plexdo-2.0.3/man/plexdo-build-concatenated.1.scd +0 -181
  100. plexdo-2.0.3/man/plexdo-build-interleaved.1.scd +0 -172
  101. plexdo-2.0.3/man/plexdo-build-randomize.1.scd +0 -164
  102. plexdo-2.0.3/man/plexdo-copy-playlist-all-users.1.scd +0 -151
  103. plexdo-2.0.3/man/plexdo-copy-playlist-to-user.1.scd +0 -145
  104. plexdo-2.0.3/src/plexdo/cli.py +0 -187
  105. plexdo-2.0.3/src/plexdo/commands/build.py +0 -311
  106. plexdo-2.0.3/src/plexdo/logs.py +0 -26
  107. plexdo-2.0.3/src/plexdo/photos.py +0 -75
  108. plexdo-2.0.3/src/plexdo/security.py +0 -67
  109. plexdo-2.0.3/tests/test_fixes.py +0 -236
  110. plexdo-2.0.3/tests/test_playlists.py +0 -170
  111. plexdo-2.0.3/tests/test_watched.py +0 -75
  112. {plexdo-2.0.3 → plexdo-3.0.0}/.github/workflows/ci.yml +0 -0
  113. {plexdo-2.0.3 → plexdo-3.0.0}/.github/workflows/publish.yml +0 -0
  114. {plexdo-2.0.3 → plexdo-3.0.0}/.github/workflows/wiki.yml +0 -0
  115. {plexdo-2.0.3 → plexdo-3.0.0}/.markdownlint-cli2.yaml +0 -0
  116. {plexdo-2.0.3 → plexdo-3.0.0}/.nvmrc +0 -0
  117. {plexdo-2.0.3 → plexdo-3.0.0}/.yamllint.yaml +0 -0
  118. {plexdo-2.0.3 → plexdo-3.0.0}/AI.policy.txt +0 -0
  119. {plexdo-2.0.3 → plexdo-3.0.0}/CONTRIBUTING.md +0 -0
  120. {plexdo-2.0.3 → plexdo-3.0.0}/LICENSE +0 -0
  121. {plexdo-2.0.3 → plexdo-3.0.0}/LICENSES/GPL-3.0-or-later.txt +0 -0
  122. {plexdo-2.0.3 → plexdo-3.0.0}/MANIFEST.in +0 -0
  123. {plexdo-2.0.3 → plexdo-3.0.0}/REUSE.toml +0 -0
  124. {plexdo-2.0.3 → plexdo-3.0.0}/SECURITY.md +0 -0
  125. {plexdo-2.0.3 → plexdo-3.0.0}/package-lock.json +0 -0
  126. {plexdo-2.0.3 → plexdo-3.0.0}/package.json +0 -0
  127. {plexdo-2.0.3 → plexdo-3.0.0}/requirements.txt +0 -0
  128. {plexdo-2.0.3 → plexdo-3.0.0}/setup.cfg +0 -0
  129. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/__init__.py +0 -0
  130. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/__main__.py +0 -0
  131. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/airdates.py +0 -0
  132. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/commands/wait.py +0 -0
  133. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/console.py +0 -0
  134. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/convert.py +0 -0
  135. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/gallery.py +0 -0
  136. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/m3u.py +0 -0
  137. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/migrate.py +0 -0
  138. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/paths.py +0 -0
  139. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/py.typed +0 -0
  140. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/records.py +0 -0
  141. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/sorting.py +0 -0
  142. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/throttle.py +0 -0
  143. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo/tokens.py +0 -0
  144. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo.egg-info/dependency_links.txt +0 -0
  145. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo.egg-info/entry_points.txt +0 -0
  146. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo.egg-info/requires.txt +0 -0
  147. {plexdo-2.0.3 → plexdo-3.0.0}/src/plexdo.egg-info/top_level.txt +0 -0
  148. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_airdates_misc.py +0 -0
  149. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_console.py +0 -0
  150. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_fanout.py +0 -0
  151. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_formats.py +0 -0
  152. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_logout.py +0 -0
  153. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_logs.py +0 -0
  154. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_paths_tokens.py +0 -0
  155. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_playback.py +0 -0
  156. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_remote_play.py +0 -0
  157. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_throttle.py +0 -0
  158. {plexdo-2.0.3 → plexdo-3.0.0}/tests/test_wait.py +0 -0
  159. {plexdo-2.0.3 → plexdo-3.0.0}/tools/derive.py +0 -0
@@ -300,11 +300,21 @@ nothing to do with this version.
300
300
  the range its dependent declares. A vulnerability in a development-only
301
301
  npm dependency is fixed by upgrading the package that pulls it in. One
302
302
  that no consistent tree avoids is left, with the reason recorded here:
303
- KaTeX's GHSA-238p-pmpm-9mq7 (low), reached through
304
- `markdownlint -> micromark-extension-math`, whose `^0.16` range no patched
305
- KaTeX release satisfies. markdownlint imports only that package's syntax
306
- extension; its KaTeX renderer is loaded and never called, and plexdo
307
- ships none of it.
303
+ - KaTeX's GHSA-238p-pmpm-9mq7 (low), reached through
304
+ `markdownlint -> micromark-extension-math`, whose `^0.16` range no
305
+ patched KaTeX release satisfies. markdownlint imports only that
306
+ package's syntax extension; its KaTeX renderer is loaded and never
307
+ called, and plexdo ships none of it.
308
+ - re2js's GHSA-gg6h-894w-4m9c (high), quadratic time matching a
309
+ lookbehind, fixed in 2.8.7. markdownlint-cli2 0.24.0 pins markdownlint
310
+ 0.42.0, which pins re2js 2.8.6 exactly; markdownlint 0.42.1 moves to
311
+ 2.9.1, so the fix arrives with the markdownlint-cli2 release that takes
312
+ it up, and is not forced in early. markdownlint's own patterns (MD011,
313
+ MD013, MD039, and the default front-matter pattern) have no lookbehind,
314
+ `.markdownlint-cli2.yaml` adds no custom rule or pattern, and the only
315
+ input is this repository's own Markdown, so the slow path is not
316
+ reached. Check for that markdownlint-cli2 release before each commit,
317
+ and drop this entry once it is taken.
308
318
  - A linter suppression covers one line and one rule, with a comment saying
309
319
  why the code cannot change instead.
310
320
  - One Google rule is knowingly not followed: its guide imports modules, not
@@ -378,14 +388,18 @@ src/plexdo/
378
388
  __main__.py python -m plexdo
379
389
  cli.py build_parser, main
380
390
  constants.py windows_app_dir, default_paths, Paths, PATHS, CONFIG_PATH,
381
- DEFAULT_CACHE_DIR, DEFAULT_SECRETS_FILE, STORE_CHOICES,
382
- config_text, CONFIG_EXAMPLE, LOG
383
- logs.py configure_logging
391
+ DEFAULT_CACHE_DIR, DEFAULT_SECRETS_FILE, session_file,
392
+ DEFAULT_SESSION_FILE, STORE_CHOICES, config_text,
393
+ CONFIG_EXAMPLE, SECRETS_EXAMPLE, LOG
394
+ logs.py configure_logging, open_debug_log, RedactingFormatter,
395
+ TimestampedFormatter
396
+ profiling.py profiled, span, usage, Run, Usage
384
397
  config.py load_config, parse_config, cached_config, config_optional,
385
398
  user_option, configured_users, expand_environment,
386
399
  check_file_permissions, Config
387
- secret_store.py backend, open_store, load, save, describe, KeyringStore,
388
- FileStore, Store
400
+ secret_store.py backend, session_backend, sessions_are_apart,
401
+ open_store, load, save, describe, KeyringStore,
402
+ FileStore, SessionStore, Store
389
403
  tokens.py load_store, lookup, store_token, admin_token,
390
404
  forget_tokens, saved_password, save_password,
391
405
  forget_logins, saved_pin, save_pin
@@ -394,7 +408,7 @@ src/plexdo/
394
408
  arguments.py add_user_argument, add_library_argument,
395
409
  add_m3u_argument, add_overwrite_argument,
396
410
  add_rating_keys_argument, add_rating_key_option
397
- cache.py cache_dir, write_cache
411
+ cache.py cache_dir, write_cache, cache_key, write_word_caches
398
412
  convert.py parse_date, format_duration
399
413
  console.py output, output_format, clean_text, table_limit,
400
414
  print_table, print_metadata, stream_record,
@@ -402,22 +416,24 @@ src/plexdo/
402
416
  formats.py to_json, to_yaml, to_csv, to_clixml, render
403
417
  records.py loaded_fields, file_paths, release_date, summary_row,
404
418
  cache_row, season_records
405
- identify.py resolve_identifier
419
+ identify.py resolve_identifier, remember_words, argument_words
406
420
  accounts.py server_for_user, account_type, resolve_user_arguments,
407
- user_roster, UserAccessError, connect_with_token,
421
+ user_roster, user_rows, UserAccessError,
422
+ connect_with_token,
408
423
  connect_as_shared_user, configured_entries,
409
424
  configured_account, configured_accounts
410
425
  sections.py resolve_section, resolve_sections,
411
426
  resolve_library_arguments
412
427
  titles.py display_title, find_item, fetch_item, fetch_show,
413
428
  non_special_episodes, shuffle_list, item_is_played,
414
- item_view_offset
429
+ item_view_offset, as_rating_key
415
430
  playlists.py resolve_playlist, finalize_playlist, copy_playlist_to,
416
431
  existing_playlist, preview_rows, require_items,
417
432
  create_for_user
418
433
  fanout.py add_user_target_arguments, deliver_playlist, run_per_user,
419
434
  report_line
420
- photos.py collect_photos, collect_library_items, photo_file_path
435
+ photos.py walk_photo_albums, AlbumVisit, is_album, album_children,
436
+ collect_photos, collect_library_items, photo_file_path
421
437
  sorting.py apply_sort
422
438
  m3u.py write_m3u
423
439
  gallery.py write_gallery_html
@@ -426,7 +442,8 @@ src/plexdo/
426
442
  throttle.py paced, throttle_delay
427
443
  playback.py fetch_address, parse_ip, url_host, url_of, play_fields,
428
444
  add_play_arguments, Candidate, IPAddress
429
- security.py scrub_password_argument
445
+ security.py scrub_password_argument, redact, redact_headers,
446
+ hide_in_logs, hide_request_secrets
430
447
  data/ the four completions and the man page, as package data
431
448
  commands/ one module per group (see COMMANDS)
432
449
  ```
@@ -445,14 +462,14 @@ Every module in `commands/` exposes exactly three names:
445
462
  every `add_parser` call so each inherits the global flags.
446
463
  - `COMMANDS` maps command name to handler.
447
464
  - `REQUIRES_PLEX` is the subset needing a server; `auth` has an empty
448
- frozenset because `login` and `write-config-example` run before a token
449
- exists, `logout` must work once the server no longer accepts one, and
450
- `set-pin` only touches the secret store.
465
+ frozenset because `login` and the two `write-*-example` commands run
466
+ before a token exists, `logout` must work once the server no longer
467
+ accepts one, and `set-pin` only touches the secret store.
451
468
 
452
- `commands/__init__.py` holds a `MODULES` tuple whose order sets the order of
453
- subcommands in `--help`, plus `register_all(sub, parents)` and
454
- `build_registry()`. Adding a command means adding a module and listing it;
455
- `cli.py` never changes.
469
+ `commands/__init__.py` holds a `MODULES` tuple, `register_all(sub, parents)`,
470
+ `build_registry()`, and `GROUPS`, which is what `--help` lists the commands
471
+ from (see COMMAND NAMES AND HELP). Adding a command means adding a module,
472
+ listing it, and naming the command in one group; `cli.py` never changes.
456
473
 
457
474
  ## PLATFORM DEFAULTS
458
475
 
@@ -550,6 +567,37 @@ removes its entry.
550
567
  - `secret_store.backend()` is chosen once per run and works before the
551
568
  config exists, with the defaults, since the migration saves secrets
552
569
  before writing the file.
570
+ - **With `secret_store: file` set outright, sessions are kept apart from
571
+ passwords.** `SESSION_KINDS` is `("tokens",)`; `session_backend()` is the
572
+ store for those kinds and `backend()` the store for the rest, and
573
+ `load`, `save`, and `describe(kind)` pick between them. Under `auto` and
574
+ `keyring` the two are the same store, a file `auto` fell back to
575
+ included. Under `file` the secrets file holds `passwords` and `pins`
576
+ alone, and the tokens go in a `SessionStore`, a `FileStore` at
577
+ `constants.DEFAULT_SESSION_FILE`: `$XDG_RUNTIME_DIR/plexdo/session.json`
578
+ when that variable is an absolute path, else `plexdo-UID/session.json`
579
+ under `tempfile.gettempdir()`, which honours `TMPDIR`, `TEMP`, and `TMP`
580
+ (`PlexDo/session.json` there on Windows). `session_file(is_windows, env,
581
+ tmpdir, uid)` takes all four as arguments so every case is testable.
582
+ The reasoning: that setting is for a machine with no keyring to unlock,
583
+ where the secrets file is all that guards a saved password, and a token,
584
+ which changes with every login and which plex.tv can reissue, does not
585
+ belong beside it or need to outlive a reboot.
586
+ - The temporary directory is writable by other accounts, so the session
587
+ directory is created with mode 0700 and then checked with `lstat`: it
588
+ must be a real directory, not a link, owned by the user. One of the
589
+ user's own that is open to group or other is tightened. Otherwise
590
+ writing is a `sys.exit` and nothing is written, and a session file
591
+ found there is ignored with a warning rather than believed. Windows has
592
+ neither check; its temporary directory is inside the user's profile.
593
+ - The first time the session store is opened, a `tokens` entry still in
594
+ the secrets file, where it was kept before, is moved to the session
595
+ file, a token already there winning, with a warning saying so. An
596
+ upgrade must not sign anyone out.
597
+ - `login` prints one note on **stderr**, in every format, when the two
598
+ stores differ: where sessions are kept, that they are apart from the
599
+ passwords and PINs, and that a session lasts until that file is cleared,
600
+ as at a reboot, when `plexdo login` is needed again.
553
601
 
554
602
  `tokens.py` is the only module that knows the three kinds.
555
603
 
@@ -582,7 +630,7 @@ that finds its account early signs in no more. When all three fail, raise
582
630
  `UserAccessError` explaining that admin rights do not override per-user
583
631
  scoping and that a `users` entry signed in once with `login --username`
584
632
  would fix it. It must be an ordinary `Exception`, **not** a `sys.exit`:
585
- `copy-playlist-all-users` catches per-user failures and continues, and
633
+ `playlist-copy --all` catches per-user failures and continues, and
586
634
  `SystemExit` would tear down the whole run. `cli.main` converts it to a
587
635
  clean exit for single-user commands. It carries a one-line `summary` so a
588
636
  loop reports each skip in one line.
@@ -639,9 +687,9 @@ present.
639
687
 
640
688
  ## GLOBAL FLAGS
641
689
 
642
- `-f/--format`, `--json`, `-v/--verbose`, `--debug`, `--dry-run`, `-W/--wide`,
643
- `--throttle`, `-V/--version`. They must work **either before or after the
644
- command name**.
690
+ `-f/--format`, `--json`, `-v/--verbose`, `--debug`, `--debug-log PATH`,
691
+ `--dry-run`, `-W/--wide`, `--throttle`, `-V/--version`. They must work
692
+ **either before or after the command name**.
645
693
 
646
694
  Implement with `_add_global_flags(parser, suppress=False)` called twice: once
647
695
  on the top-level parser with ordinary defaults, and once on an
@@ -650,16 +698,92 @@ on the top-level parser with ordinary defaults, and once on an
650
698
  The SUPPRESS default is essential, not cosmetic. A subparser parses into its
651
699
  own namespace and then copies **every** attribute onto the main one, so
652
700
  ordinary defaults on the inherited copies would silently clobber a flag given
653
- before the subcommand: `plexdo --json list-users` would print a table.
701
+ before the subcommand: `plexdo --json users-list` would print a table.
702
+
703
+ Add them to an argument group titled `global options`, in order of their
704
+ long names, on both parsers. argparse carries a parent's groups over by
705
+ title, so every command's help lists them apart from its own options.
654
706
 
655
707
  `--format` writes to `dest="format"`; `--json` is a `store_const` alias
656
708
  writing to that same destination, so there is one attribute and no way for the
657
- two to disagree. All logging goes to stderr only.
709
+ two to disagree. All logging goes to stderr only, or to the `--debug-log`
710
+ file; never to stdout.
711
+
712
+ ### The debug log
713
+
714
+ `--debug-log PATH` sends everything `--debug` would show to PATH instead,
715
+ and stderr keeps the level it would have without `--debug`; `--debug` is
716
+ not needed with it. `logs.open_debug_log` opens it with `os.open`,
717
+ `O_APPEND | O_CREAT`, mode 0600, so a new file is its owner's alone and
718
+ earlier runs are kept, and line buffered, so a killed run loses nothing it
719
+ logged; a path that cannot be opened is a `sys.exit` naming it, before the
720
+ command runs. Expand `~`.
721
+
722
+ `TimestampedFormatter` starts **every line**, not every record, with
723
+ `time.time_ns()` at the moment it is formatted, then `LEVEL logger:
724
+ message`; a traceback or a cProfile table carries the stamp on each line.
725
+ `LogRecord.created` is a float and cannot hold nanoseconds at today's epoch,
726
+ so it is not used.
727
+
728
+ `profiling.profiled(enabled, command)` is the context `cli.main` runs the
729
+ command in, after `scrub_password_argument` so no `--password` reaches the
730
+ run start line: a `profiling.Run` with `--debug-log`, `nullcontext()`
731
+ without. `profiling.span(name)` times one phase - `migrate`, `connect`,
732
+ `resolve-arguments`, `command:NAME` - and is a no-op with no Run active.
733
+ The Run writes, as `key=value` pairs so the file is as easy to grep as to
734
+ read:
735
+ - `plexdo.profile: run start` - plexdo, Python, plexapi, and requests
736
+ versions, platform, pid, command, `json.dumps(sys.argv)`;
737
+ - `span` lines - wall, CPU, user and system time, `cpu_percent`, memory,
738
+ and the HTTP calls and wait inside the span;
739
+ - `plexdo.http: send` / `recv` / `fail` - Run replaces
740
+ `requests.Session.send` for the run and restores it after, so every
741
+ session, plexapi's included, is seen. `send` has an id, method, URL,
742
+ body size, and headers; `recv` the status, reason, `wait_ns` (send to
743
+ whole response, by `perf_counter_ns`), `headers_ns` (requests'
744
+ `elapsed`), body size and type, and headers; `fail` the error, before
745
+ re-raising. Never read the body of a `stream=True` response, which would
746
+ consume what `read` streams: give its Content-Length. A redirect comes
747
+ back through `send`; a thread-local depth keeps the nested hops out of
748
+ the totals, so no wait counts twice;
749
+ - `resources` every `SAMPLE_SECONDS` (5) from a daemon thread, over the
750
+ interval since the last;
751
+ - `run end` - status (`SystemExit` code, `interrupted`,
752
+ `exception:TYPE`), totals, the HTTP count, wait, its share of the run,
753
+ and the slowest request, then cProfile's top `TOP_FUNCTIONS` (40) by
754
+ cumulative and by own time. Python 3.12 allows one profiler at a time,
755
+ so `enable()` raising `ValueError` is logged and the run goes on
756
+ without the tables.
757
+ CPU is `time.process_time_ns()` with `os.times()` for the user/system split.
758
+ Memory without a dependency: current RSS from `/proc/self/statm` on Linux
759
+ and `GetProcessMemoryInfo` through ctypes on Windows, the peak from
760
+ `resource.getrusage` (`ru_maxrss`, KiB except bytes on macOS) or Windows;
761
+ Linux updates the peak lazily, so report at least the current figure, and
762
+ `None` where the platform gives nothing. psutil would be a dependency for
763
+ one feature.
764
+
765
+ Redaction is in `security`. Both handlers, stderr and file, use
766
+ `RedactingFormatter`, which redacts the **whole formatted record**,
767
+ traceback included, since urllib3 logs request lines with their query
768
+ string and requests' exceptions quote URLs. `redact` replaces: the value of
769
+ any `name=value`, `name: value`, or quoted-key pair whose name is a token,
770
+ password, passwd, secret (as a suffix: `X-Plex-Token`, `authToken`), `pin`,
771
+ `X-Plex-Pin`, `X-Plex-Code`, `verificationCode`, `Authorization`, or a
772
+ cookie header, its scheme with it; the password in `scheme://user:pass@`;
773
+ and every value `hide_in_logs` was given. That registry is fed by
774
+ `secret_store.load` and `save`, the password prompt and
775
+ `scrub_password_argument`, and `hide_request_secrets`, which `send` calls
776
+ before the request goes out with the secret-named query parameters and
777
+ headers, a Basic credential's decoded password included. Values under 8
778
+ characters are not registered: a four-digit PIN would blank out every
779
+ matching number; named, it is still redacted. Bodies are never logged,
780
+ only their sizes. Name the registry what it is (`_KnownSecrets`), per the
781
+ naming rule above.
658
782
 
659
783
  ## OUTPUT
660
784
 
661
785
  `output(data, args)` dispatches on `output_format(args)`, accepting **either**
662
- a single record or a list so `show-metadata` and `list-titles` share the path.
786
+ a single record or a list so `show-metadata` and `library-list` share the path.
663
787
  No command reaches for `json.dumps` directly.
664
788
 
665
789
  ### Formats
@@ -721,11 +845,12 @@ no benefit. Announce once at info level with the expected duration and how to
721
845
  turn it off.
722
846
 
723
847
  Apply wherever a loop makes one request per element and the count can exceed
724
- 50: the seasons walk in `list-titles`, `find-missing -A`, applying watched
725
- state in `copy-watched`, removing entries in `clean-playlist`, reading each
726
- source in `build-concatenated`, `collect_photos`, and the per-user loop in
727
- `fanout.run_per_user`, which serves `copy-playlist-all-users` and every build
728
- command given `-a`. Functions needing it take an optional `args`.
848
+ 50: the seasons walk in `library-list`, `missing-find -A`, applying watched
849
+ state in `show-watched-cp`, removing entries in `playlist-clean`, reading each
850
+ source in `merge-build`, `collect_photos`, the walk of a show library in
851
+ `collect_library_items`, and the per-user loop in
852
+ `fanout.run_per_user`, which serves `playlist-copy --all`, and
853
+ `playlist-build` and `merge-build` given `-a`. Functions needing it take an optional `args`.
729
854
 
730
855
  ## RECORDS
731
856
 
@@ -768,12 +893,15 @@ content.
768
893
 
769
894
  Every argument naming an **existing** playlist accepts a title or a ratingKey
770
895
  through `resolve_playlist`; no command may call `plex.playlist(name)`
771
- directly. A numeric value that is no item at all is a clean `sys.exit`, not
896
+ directly. What counts as a ratingKey is decided by `titles.as_rating_key`,
897
+ **ASCII digits and nothing else**: `int()` is too generous a test, taking
898
+ `1_000`, `+7`, ` 12 `, and digits of other scripts, any of which may be what
899
+ a playlist is called. A numeric value that is no item at all is a clean `sys.exit`, not
772
900
  plexapi's `NotFound` escaping as a traceback: look it up with
773
901
  `titles.find_item`, which returns None, and `fetch_item` wraps that for every
774
902
  other fatal lookup, `fetch_show` included. Nothing calls `fetchItem` bare.
775
903
 
776
- Every build command constructs the list fully in memory, validates it is
904
+ Every command that creates a playlist constructs the list fully in memory, validates it is
777
905
  non-empty, prints a numbered preview, and makes exactly one `createPlaylist`
778
906
  call. `finalize_playlist` enforces this and returns `"created"` or
779
907
  `"replaced"`.
@@ -799,32 +927,151 @@ Without `--overwrite`, a taken name falls back to appending `" admin copy"`;
799
927
  if that is taken too the target is skipped with a warning and nothing is
800
928
  touched.
801
929
 
930
+ ## COMMAND NAMES AND HELP
931
+
932
+ A command is named `<object>-<action>`: what it acts on, then what it does
933
+ to it (`playlist-list`, `playlist-export`, `libraries-list`). Plural objects
934
+ list many (`playlists-list USER`), singular ones show one (`playlist-list
935
+ USER PLAYLIST`). The exceptions are fixed names, kept exactly:
936
+ `merge-build`, `show-watched-cp`, `missing-find`, `titles-export`, the
937
+ single words (`search`, `read`, `rescan`, `status`, `wait`, `login`,
938
+ `logout`), and `show-metadata`, `player-remote-play`, `set-pin`,
939
+ `write-config-example`, and `write-secrets-example`. There are no aliases:
940
+ one command, one name, so `COMMANDS` has exactly one entry per command.
941
+
942
+ Releases up to 2.1.0-rc.1 named commands `<action>-<object>`. 3.0.0 renamed
943
+ them, an incompatible change and so a MAJOR one, and also
944
+ folded `build-interleaved`, `build-chronological`, `build-randomize`, and
945
+ `append-playlist` into `playlist-build`, and `copy-playlist-all-users` and
946
+ `copy-playlist-to-user` into `playlist-copy`, and removed
947
+ `build-concatenated` and `list-titles` (whose alias `list-library` became
948
+ `library-list`). None of the old names is accepted.
949
+
950
+ `plexdo --help` lists the commands under five headings, in this order, and
951
+ sorted by name within each. `commands.GROUPS` is that list, and every
952
+ command must be in exactly one group (a test holds it to the registry):
953
+
954
+ | Group | Commands |
955
+ | --- | --- |
956
+ | libraries and titles | `album-list`, `albums-list`, `libraries-list`, `library-list`, `missing-find`, `search`, `show-list`, `show-metadata`, `titles-export` |
957
+ | playlists | `merge-build`, `playlist-build`, `playlist-clean`, `playlist-copy`, `playlist-export`, `playlist-list`, `playlist-remove`, `playlists-list` |
958
+ | playback and watched state | `player-remote-play`, `read`, `show-watched-cp`, `wait` |
959
+ | server | `rescan`, `status`, `users-list` |
960
+ | account and configuration | `login`, `logout`, `set-pin`, `write-config-example`, `write-secrets-example` |
961
+
962
+ argparse can only print subcommands as one flat list in registration order,
963
+ so pass `help=argparse.SUPPRESS` to `add_subparsers` and build the grouped
964
+ list into the parser's description, with `RawDescriptionHelpFormatter`:
965
+ the name padded to the longest, then the summary each `add_parser(help=...)`
966
+ gave, wrapped to 79 columns with a hanging indent. Give `add_subparsers`
967
+ `prog="plexdo"` and the parser `usage="%(prog)s [global options] <command>
968
+ [arguments]"`; without the first, every command's usage line repeats the
969
+ top-level usage. Summaries are one sentence, in the imperative, short
970
+ enough to fit their line. Positional metavars are upper case (`USER`,
971
+ `LIBRARY`, `PLAYLIST`, `NAME`, `QUERY`, `PATH`) except `ratingKey`, which is
972
+ spelled as Plex spells it.
973
+
974
+ After registration, `cli._tidy_command_help` goes over every command: it
975
+ sorts the `global options` group last, since a parent's groups otherwise
976
+ come before the groups a command adds, and, where a command set no `usage`
977
+ of its own, formats one from the command's own actions and appends
978
+ `[global options]`, so nine inherited flags do not bury the two that
979
+ matter. Both reach into argparse's protected attributes, which has no
980
+ public way to do either; keep that in the one function.
981
+
982
+ `playlist-build` and `playlist-copy` take a flag that decides what their
983
+ positional arguments mean, which argparse cannot express. They collect the
984
+ positionals unnamed, set `check_arguments` with `set_defaults` to a
985
+ callable taking the namespace, and `cli.parse_arguments(parser, argv)`,
986
+ which `main` uses in place of `parse_args`, runs it: the callable names the
987
+ arguments for the mode (`args.user_id`, `args.name`, `args.rating_keys`,
988
+ ...) or calls `parser.error`, exit 2. That happens before anything connects
989
+ and before user titles are resolved, so `resolve_user_arguments` finds
990
+ `user_id` where it expects it. Each has a hand-written multi-line `usage`
991
+ and a description saying what each mode takes.
992
+
802
993
  ## COMMANDS
803
994
 
804
995
  ### Listing and searching
805
996
 
806
- - `list-libraries` - columns `id`, `type`, `title`; writes the libraries cache.
807
- - `list-titles LIBRARY [--album A]` - registered with
808
- `aliases=["list-library"]`. argparse reports whichever spelling was typed,
809
- so `COMMANDS` needs an entry for both, giving 30 registry entries for 29
810
- commands; the smoke test floor must allow for it. Table columns are
997
+ - `libraries-list` - columns `id`, `type`, `title`; writes the libraries cache.
998
+ - `library-list LIBRARY [--album A]` - table columns are
811
999
  `ratingKey`, `title`, `releaseDate`, `rating`, `studio`; a machine-readable
812
1000
  format carries every listed field and, for a show library, nests each show's
813
1001
  episodes under a `seasons` object keyed by season name. Always cache the
814
1002
  minimal `ratingKey`/`title` rows whatever the caller asked to see.
815
- - `list-show RATINGKEY [--m3u P] [-p PREFIX]` - fails fast if not a `Show`.
816
- - `list-users` - `id`, `type`, `title`, `username`, `email`. `account_type`
1003
+ - `albums-list LIBRARY` (`commands/albums.py`) - the albums of a music
1004
+ (section type `artist`) or photo library; any other type exits 1. Music
1005
+ albums come from `section.albums()`. A photo library is walked depth
1006
+ first from `section.all()`, opening each album with one
1007
+ `fetchItems(f"{album.key}/children")` and splitting the children by
1008
+ `TAG == "Directory"` into nested albums, walked next so they follow their
1009
+ parent, and contents; a ratingKey met twice is listed once. Table
1010
+ columns are `ratingKey`, `artist` (music only), `title`, `year`, `items`,
1011
+ `played`; a music table reads `leafCount`/`viewedLeafCount` from the
1012
+ listing and opens no album. Every other format, and every photo listing,
1013
+ opens each album through `paced`. A machine-readable record carries
1014
+ `ratingKey`, `artist`, `title`, `year`, `items`, `duration`, `state`,
1015
+ `played`, `playedItems`, `inProgressItems`, `lastViewedAt`, `progress`,
1016
+ and `contents`; each content record `index`, `ratingKey`, `type`, `disc`,
1017
+ `track`, `title`, `artist`, `duration`, `state`, `played`, `viewCount`,
1018
+ `lastViewedAt`, `viewOffset`, `progress`, `files`. Every key is always
1019
+ present, null where it does not apply. Fields are read from `vars()`, so
1020
+ nothing reloads. An item has a state only if it carries `viewCount`
1021
+ (tracks and clips; photos do not): `in progress` when `viewOffset > 0`,
1022
+ even if played before, else `played` when `viewCount > 0`, else
1023
+ `unplayed`. An album is `in progress` if any item is, `played` if every
1024
+ playable item is, `unplayed` if none is, else `in progress`; null with no
1025
+ playable item. `progress` is a percentage to one decimal, null unless the
1026
+ state is `in progress`: an item's resume point over its duration; an
1027
+ album's played running time (played items in full, items in progress up
1028
+ to their resume point) over the total of its playable items. `viewOffset`
1029
+ is likewise null unless in progress.
1030
+ - `album-list RATINGKEY` (also `commands/albums.py`) - one album, by
1031
+ `fetch_item` and one `/children` fetch. Refused with exit 1 unless the
1032
+ item is a Directory whose `type` is `album` (music) or `photo` (a photo
1033
+ album; a photo shares the type but not the TAG), since shows, seasons,
1034
+ and artists are Directories too. Nested photo albums are split off,
1035
+ logged at INFO, and counted in the table summary as `nested albums`, not
1036
+ listed. JSON and YAML output the same `album_record` as `albums-list`;
1037
+ CSV and CLIXML output its `contents` alone. A table prints the album as a
1038
+ metadata block with only the keys that apply (`ratingKey`, `artist`,
1039
+ `title`, `year`, `items`, `nested albums`, `length`, `state`, `played` as
1040
+ "N of M", `in progress`, `progress` as "P%", `last played`), a blank
1041
+ line, then one row per item: `#`, `ratingKey`, `track` (as `2-03` when
1042
+ the album spans several discs; music only), `title`, `artist` (only when
1043
+ some track's artist differs from the album's), `length`, then, unless the
1044
+ album holds photos alone, `state`, `progress` ("3:20 / 9:46 (34.1%)" for
1045
+ an item in progress, else empty), and `last played` as a date.
1046
+ - `show-list RATINGKEY [--m3u P] [-p PREFIX]` - fails fast if not a `Show`.
1047
+ - `users-list` - `id`, `type`, `title`, `username`, `email`, from
1048
+ `accounts.user_rows`, which the `users` section of `status` shows too, so
1049
+ the two cannot come to differ; the command once printed three of the
1050
+ five while its documentation promised all of them. `account_type`
817
1051
  returns managed/home/friend/shared. plexapi exposes `restricted` as the raw
818
1052
  XML **string**, so `"0"` is truthy and a naive check labels every account
819
1053
  managed.
820
- - `list-playlists USER`, `list-playlist USER PLAYLIST [--m3u P] [-p PREFIX]`.
1054
+ - `playlists-list USER`, `playlist-list USER PLAYLIST [--m3u P] [-p PREFIX]`.
821
1055
  - `show-metadata RATINGKEY` - type-specific builders via `_METADATA_BUILDERS`
822
1056
  keyed on `item.type`, plus the file paths. A table names a lone file `file`
823
1057
  and numbers several `file 1`, `file 2`; other formats carry a `files` list.
824
- - `search USER QUERY [--media-type T] [--library-id L]` - per-library failures
825
- log a warning and continue.
826
- - `find-missing SHOW [-l LIBRARY] [-A] [-s SEASONS] [--include-specials]` -
827
- names one show by default, resolved through `resolve_identifier`. `-A`
1058
+ - `search USER QUERY [--media-type T] [--library-id L]` - with
1059
+ `--media-type`, search only the libraries whose section type can hold it
1060
+ (`episode` in `show`, `track` and `album` in `artist`): asked for a type
1061
+ it cannot hold, plexapi raises "Unknown libtype", once per library. A
1062
+ library named with `--library-id` that cannot hold the type is a
1063
+ `sys.exit`. A library whose search raises is skipped, logged at DEBUG,
1064
+ and the rest are searched; the failures are reported, as **one** warning
1065
+ starting `Search failed in`, **only when no result came back at all**.
1066
+ With results in hand a library that could not be searched is noise.
1067
+ - `missing-find SHOW [-l LIBRARY] [-A] [-s SEASONS] [--include-specials]` -
1068
+ names one show by default, resolved through `resolve_identifier`. A SHOW
1069
+ written as a ratingKey is fetched outright first, and used if it is a
1070
+ show: listing every show of every library to look up a number was the
1071
+ slow part. One `section.search(title=..., libtype="show")` per show
1072
+ library is still made, to give the usual warning when another show is
1073
+ *titled* that number ("24", "1923"). A number that is no show's key falls
1074
+ through to the roster, where it may be a title. `-A`
828
1075
  sweeps every show and refuses a SHOW argument; `-s` takes one number or a
829
1076
  comma list and applies only to a single show. For each season the run starts
830
1077
  at 0 when an episode 0 exists and otherwise at 1, ending at the highest
@@ -837,41 +1084,81 @@ touched.
837
1084
 
838
1085
  ### Building and copying
839
1086
 
840
- - `build-interleaved NAME RATINGKEY...` - round-robin across shows.
841
- - `build-chronological NAME RATINGKEY...` - sorted by air date. A missing date
842
- is estimated from neighbours in the same season: collect up to 6 either
843
- side, require at least 3 known dates (so at least 2 intervals), take the
844
- median interval, estimate from latest-previous `+median` and earliest-next
845
- `-median`, averaging when both exist, and prompt only when that is
846
- impossible. A film with no date has no neighbours, so it is prompted for
847
- directly. `prompt_for_date` takes **any** item: name an episode by show,
848
- season, and episode, with `??` for a number Plex lacks rather than `00`,
849
- which would read as a real episode 0, and anything else by title and year.
850
- It once read episode-only attributes and crashed on every undated film.
851
- Running out of input is a `sys.exit` naming the item, not an `EOFError`.
852
- - `build-randomize USER SOURCE DEST` - Fisher-Yates via `secrets.randbelow`.
853
- - `build-concatenated USER NAME PLAYLIST... [--unique]` - joins the sources
854
- end to end, keeping the order within each and the order they were named in.
855
- Read **every** source before writing anything, so naming a source as the
856
- destination works under `--overwrite`: the items are already in memory when
857
- the old playlist goes. Duplicates are kept by default - a concatenation is
858
- faithful, and Plex permits a repeat - while `--unique` keeps only an item's
859
- first appearance, matched on ratingKey rather than title, and reports how
860
- many it skipped. Warn about an empty source rather than passing over it: a
861
- typo that resolved to the wrong playlist looks exactly like a playlist that
862
- happens to be empty.
863
- - `copy-playlist-all-users USER PLAYLIST` - skips the source user, prints the
864
- item list **once** before the loop and passes `preview=False` thereafter,
865
- then one line per user (`created`, `replaced`, `skipped`, `failed`) printed
866
- as it completes. Because the caller surfaces the skip reason, the message
867
- inside `copy_playlist_to` is `LOG.info`, not a warning that would duplicate
868
- every skip on stderr.
869
- - `copy-playlist-to-user USER PLAYLIST USER DEST` - a table shows the item
870
- list then the outcome line; a machine-readable format shows the outcome
871
- record **alone**, as `copy-playlist-all-users` does, because two documents
872
- on one stream parse as neither.
873
- - `append-playlist USER PLAYLIST RATINGKEY...`, `remove-playlist USER PLAYLIST`.
874
- - `clean-playlist USER PLAYLIST [--include-partial]` - removes the watched
1087
+ - `playlist-build (-I | -C) NAME RATINGKEY...`,
1088
+ `playlist-build -R USER SOURCE DEST`,
1089
+ `playlist-build -A USER PLAYLIST RATINGKEY...` - one command, four modes,
1090
+ as a required mutually exclusive group of `store_const` flags writing
1091
+ `dest="mode"`. The positionals are `first` and `rest` (`nargs="+"`), both
1092
+ shown as `ARG`, so that an option between the first argument and the
1093
+ others still parses; `_check_build` names them for the mode. The wrong
1094
+ count for the mode, or a ratingKey that is not an integer, is a usage
1095
+ error, exit 2, as is `-A` with any of `-o`, `-u`, `-a`, `--m3u`, and
1096
+ `-p`, which only make sense for a playlist being created. With too few
1097
+ arguments for `first` and `rest` at all, argparse reports only "the
1098
+ following arguments are required: ARG", before any flag is looked at;
1099
+ the parser's `error` is wrapped to say instead what each mode takes,
1100
+ naming together the modes that take the same ("-I and -C take ..."). `-A` is
1101
+ `--append`; `-a` stays `--all-users`.
1102
+ - `-I/--interleaved` - round-robin across shows.
1103
+ - `-C/--chronological` - sorted by air date. A missing date
1104
+ is estimated from neighbours in the same season: collect up to 6 either
1105
+ side, require at least 3 known dates (so at least 2 intervals), take the
1106
+ median interval, estimate from latest-previous `+median` and
1107
+ earliest-next `-median`, averaging when both exist, and prompt only when
1108
+ that is impossible. A film with no date has no neighbours, so it is
1109
+ prompted for directly. `prompt_for_date` takes **any** item: name an
1110
+ episode by show, season, and episode, with `??` for a number Plex lacks
1111
+ rather than `00`, which would read as a real episode 0, and anything
1112
+ else by title and year. It once read episode-only attributes and crashed
1113
+ on every undated film. Running out of input is a `sys.exit` naming the
1114
+ item, not an `EOFError`.
1115
+ - `-R/--randomize` - Fisher-Yates via `secrets.randbelow`.
1116
+ - `-A/--append` - fetches every ratingKey as the user sees the server,
1117
+ exits 1 if any is not found, previews, then adds them in one call.
1118
+ Refuse a smart playlist before fetching anything, as `playlist-clean`
1119
+ does: plexapi raises `BadRequest` from `addItems`, which is also
1120
+ caught there and turned into a `sys.exit`.
1121
+ - `merge-build USER NAME FIRST SECOND [--ratio A:B] [-s] [-U]` - mixes two
1122
+ playlists in turn, A items from FIRST then B from SECOND, default 1:1,
1123
+ each keeping its own order. Parse the ratio in argparse (`type=`), so a
1124
+ bad one is a usage error, exit 2: two ASCII whole numbers above 0 joined
1125
+ by `:`. A side has run out on the first turn it has nothing to give; a
1126
+ turn cut short because fewer items were left still counts. From that turn
1127
+ the rest of the other follows by default, rather than being dropped, and
1128
+ `--verbose` reports how many items follow unmixed; `-s/--stop-short`
1129
+ leaves them out instead, the turn included, and reports how many. Sizes
1130
+ that match the ratio run out together, with no tail, so `-s` changes
1131
+ nothing. Repeats are kept; `-U/--unique` passes over an item already
1132
+ taken, by ratingKey, and the next item of the same side takes its place,
1133
+ so the ratio holds, and under `-s` a side left with only repeats has run
1134
+ out. `-U`, not `-u`: `-u` is `--user`, as on `playlist-build`. Read both
1135
+ sources before writing anything, so either may be the destination under
1136
+ `--overwrite`: the items are already in memory when the old playlist
1137
+ goes. Warn about an empty side rather than passing over it: a typo that
1138
+ resolved to the wrong playlist looks exactly like a playlist that happens
1139
+ to be empty.
1140
+ - `playlist-copy -A USER PLAYLIST [-o]`,
1141
+ `playlist-copy -T USER PLAYLIST TO_USER DEST [-o]` - one command, two
1142
+ modes, as a required mutually exclusive group writing `dest="copy_to"`.
1143
+ The positionals are `source_user_id`, `source_playlist`, and `target`
1144
+ (`nargs="*"`); `_check_copy` requires none for `-A` and exactly two for
1145
+ `-T`, naming them `user_id` and `dest`, and anything else is a usage
1146
+ error, exit 2.
1147
+ - `-A/--all` - skips the source user, prints the
1148
+ item list **once** before the loop and passes `preview=False`
1149
+ thereafter, then one line per user (`created`, `replaced`, `skipped`,
1150
+ `failed`) printed as it completes. Because the caller surfaces the skip
1151
+ reason, the message inside `copy_playlist_to` is `LOG.info`, not a
1152
+ warning that would duplicate every skip on stderr.
1153
+ - `-T/--to` - a table shows the item list then the outcome line; a
1154
+ machine-readable format shows the outcome record **alone**, as `-A`
1155
+ does, because two documents on one stream parse as neither.
1156
+ - `playlist-remove USER PLAYLIST` - names the playlist by its title in what
1157
+ it prints, however it was given. `--dry-run` says on stderr which
1158
+ playlist would be deleted, by title and ratingKey: a rehearsal that
1159
+ prints nothing cannot be told from one that found nothing to do, and
1160
+ every `--dry-run` must say what it would have done without `--verbose`.
1161
+ - `playlist-clean USER PLAYLIST [--include-partial]` - removes the watched
875
1162
  entries and leaves the rest in their existing order. Preview first, then one
876
1163
  `removeItems` call per entry so the loop is paced like any other per-item
877
1164
  loop; the playlist item IDs come from the listing plexapi already holds, so
@@ -880,26 +1167,29 @@ touched.
880
1167
  the removal list, so it lines up with what Plex shows. A part-played item is
881
1168
  **kept** by default - it is the one being watched right now, not a watched
882
1169
  one - and `--include-partial` drops it, labelled `partial` rather than
883
- `played`. Refuse a smart playlist: its contents come from a filter, and Plex
1170
+ `played`. **The resume point is looked at first**: an item played before
1171
+ and part way through again has a view count and a resume point, and it is
1172
+ the second that says the user is in the middle of it. Refuse a smart playlist: its contents come from a filter, and Plex
884
1173
  answers a delete on one of its entries with a 400. Warn when every entry is
885
1174
  going, since Plex drops a playlist that loses its last item.
886
1175
 
887
1176
  #### Who a built playlist is for
888
1177
 
889
- All four build commands take `-u/--user` and `-a/--all-users`, registered
890
- through `fanout.add_user_target_arguments` as a mutually exclusive group -
891
- writing the pair out on each parser is what trips `duplicate-code`. Without
892
- either, the playlist goes where it always did: the account the items came
893
- from, which is the admin server for the two that take rating keys and the
894
- source user's server for the two that take playlists. `deliver_playlist` takes
895
- both servers for exactly that reason. `-a` targets `accounts.user_roster`,
896
- which **includes the admin** at id 0, because the admin is a user of the
897
- server too.
1178
+ `playlist-build`, except under `-A`, and `merge-build` take `-u/--user` and
1179
+ `-a/--all-users`, registered through `fanout.add_user_target_arguments` as a
1180
+ mutually exclusive group - writing the pair out on each parser is what trips
1181
+ `duplicate-code`. Without either, the playlist goes to the account the items
1182
+ came from, which is the admin server for `-I` and `-C`, which take rating
1183
+ keys, and the source user's server for `-R` and `merge-build`, which take
1184
+ playlists. `deliver_playlist`
1185
+ takes both servers for exactly that reason. `-a` targets
1186
+ `accounts.user_roster`, which **includes the admin** at id 0, because the
1187
+ admin is a user of the server too.
898
1188
 
899
1189
  `fanout.run_per_user` holds the loop: print the item list once, then run a
900
1190
  per-user action and print its one-line outcome as it completes, so a long run
901
1191
  shows progress; in a machine-readable format emit one record per user instead,
902
- since one document cannot carry both. `copy-playlist-all-users` uses the same
1192
+ since one document cannot carry both. `playlist-copy --all` uses the same
903
1193
  loop with its own action and its own target list - the accounts a playlist is
904
1194
  shared with rather than the roster - so the loop is not written twice.
905
1195
 
@@ -911,7 +1201,7 @@ others left.
911
1201
 
912
1202
  ### Watched state
913
1203
 
914
- `copy-watched USER USER [-1] [-l LIBRARY] [-t KEY] [--unwatch]`.
1204
+ `show-watched-cp USER USER [-1] [-l LIBRARY] [-t KEY] [--unwatch]`.
915
1205
  `_select_winner(first, second, unwatch)` returns `(winner, loser)` or None:
916
1206
 
917
1207
  | situation | default | `--unwatch` |
@@ -924,13 +1214,38 @@ A `lastViewedAt` of `None` must never win: map it to `datetime.min` when the
924
1214
  latest wins and `datetime.max` when the earliest does. Plex rewrites
925
1215
  `viewOffset` continuously during playback, so treat offsets within
926
1216
  `_OFFSET_TOLERANCE_MS` (10s) as identical, or every run reports spurious
927
- changes. Two fully played states need no sync at all.
1217
+ changes.
1218
+
1219
+ A state is two things, **the played flag and the resume point, and both are
1220
+ copied as they stand**; the view count is used only to tell played from
1221
+ not, and is never copied. So two states match when the flags are equal and
1222
+ the offsets are within tolerance, whether or not the item is played: an item
1223
+ played before and part way through again differs from one simply played.
1224
+ Two fully played states with no resume point need no sync at all.
1225
+
1226
+ `_planned_steps(winner, loser)` gives the calls that bring the loser to the
1227
+ winner's state, and the plan's `action` is those steps joined by `+`:
1228
+ `markPlayed` or `markUnplayed` when the flags differ; else `clearProgress`
1229
+ when the winner has no resume point and the loser has one; then
1230
+ `setProgress` when the winner has one. Marking played or unplayed clears
1231
+ the resume point on the server, so it comes **before** `updateProgress`,
1232
+ and `clearProgress` is done by marking the item what it already is. It was
1233
+ once applied only when the loser was fully played, so `--unwatch` planned
1234
+ `markUnplayed` for an item one user was part way through, called nothing,
1235
+ and reported the same change on every run.
1236
+
1237
+ `-t KEY` names what to compare. A movie, episode, track, or clip is
1238
+ compared by itself. A show or season stands for its `episodes()`, an artist
1239
+ or album for its `tracks()`, each member compared on its own: a
1240
+ container's own played flag only says whether **every** member is played,
1241
+ so comparing containers found two users at different points in a show
1242
+ identical. Any other type is a `sys.exit` saying it has no watched state.
928
1243
 
929
1244
  plexapi renamed these methods, so call through a helper trying both spellings
930
1245
  (`markPlayed`/`markWatched`, `markUnplayed`/`markUnwatched`) and read played
931
1246
  state from `isPlayed`, then `isWatched`, then `viewCount`. That read and the
932
1247
  resume-point read are `titles.item_is_played` and `titles.item_view_offset`,
933
- because `clean-playlist` needs the same two and the rules must not be written
1248
+ because `playlist-clean` needs the same two and the rules must not be written
934
1249
  twice. All three attributes default to a bool or a number rather than None, so
935
1250
  reading them does not satisfy plexapi's reload condition.
936
1251
 
@@ -940,13 +1255,18 @@ Watch state lives on leaf items: map `movie`->`movie`, `show`->`episode`,
940
1255
 
941
1256
  ### Exporting
942
1257
 
943
- - `export-playlist USER PLAYLIST PATH`, `export-titles LIBRARY PATH
1258
+ - `playlist-export` and `playlist-list --m3u` are two ways to the same
1259
+ file, kept on purpose; both write through the one
1260
+ `commands.playlists._export_m3u`, so neither can write it differently.
1261
+ - `playlist-export USER PLAYLIST PATH`, `titles-export LIBRARY PATH
944
1262
  [--sort alpha|date|random] [--album A]`.
945
1263
  - M3U carries Plex server filesystem paths, `#EXTINF` seconds from
946
1264
  `duration`/1000 with `-1` fallback, and skips items with no path.
947
1265
  - Photo libraries export a self-contained Spotlight.js gallery. **No Plex HTTP
948
1266
  URLs anywhere in it**: both `href` and `src` are server filesystem paths.
949
- - `-p/--prefix` on all eight export commands, registered through
1267
+ - `-p/--prefix` on every command that exports (`playlist-list`, `show-list`,
1268
+ `playlist-export`, `titles-export`, `playlist-build`, `merge-build`),
1269
+ registered through
950
1270
  `paths.add_prefix_argument` - repeating the help text inline trips
951
1271
  `duplicate-code`. `mapper_for(plex, args)` returns `identity` without a
952
1272
  prefix, so the default costs no extra call. The prefix replaces the
@@ -957,24 +1277,57 @@ Watch state lives on leaf items: map `movie`->`movie`, `show`->`episode`,
957
1277
 
958
1278
  ### Photos
959
1279
 
960
- `collect_photos` walks `section.all()` (albums) then `palbum.photos()`.
1280
+ `photos.walk_photo_albums(section, args)` is the one walk of a photo
1281
+ library, used by `albums-list`, `library-list`, and `titles-export`. It
1282
+ starts from `section.all()` and opens each album with one
1283
+ `fetchItems(f"{album.key}/children")`, splitting the children by
1284
+ `TAG == "Directory"` into nested albums, walked next so they follow their
1285
+ parent, and contents. It returns an `AlbumVisit` per album: the album, its
1286
+ contents, and the titles of the albums around it ending with its own. The
1287
+ photos of an album inside another are in neither the library listing nor
1288
+ the outer album's own children, so a walk that stops at the top level
1289
+ loses them, as `collect_photos` once did.
1290
+
1291
+ Two guards, both needed. A ratingKey met twice is passed over: that lists
1292
+ once an album the server shows at the top level as well as nested, and
1293
+ stops albums that contain one another from being walked for ever. And
1294
+ nesting is followed `MAX_ALBUM_DEPTH` (16) albums deep, since a loop check
1295
+ cannot stop a server that keeps producing new albums; what lies deeper is
1296
+ left out with **one** warning naming where the walk stopped.
1297
+
1298
+ `collect_photos` takes the `type == "photo"` contents of each visit,
1299
+ leaving clips out. `--album` matches a title, ignoring case, against every
1300
+ title in the visit's path, so naming an album takes the albums inside it
1301
+ too, and an album nested anywhere can be named. A name matching no album
1302
+ is a `sys.exit`; an album that exists and is empty is not.
961
1303
  **Never** `section.search(libtype="photo")`: Plex requires a non-empty query
962
1304
  string and returns nothing for an empty one, so the library appears empty.
963
1305
 
964
1306
  ### Streaming and server management
965
1307
 
966
- - `read LIBRARY RATINGKEY` - streams to `sys.stdout.buffer` in 64KB chunks via
967
- `requests.get(stream=True)`. Validate the item's `librarySectionID` matches.
1308
+ - `read RATINGKEY` - streams to `sys.stdout.buffer` in 64KB chunks via
1309
+ `requests.get(stream=True)`. It takes no library: a ratingKey is unique
1310
+ on the server, and an argument that only existed to be checked against
1311
+ the item was one more thing to look up and type.
968
1312
  Catch `BrokenPipeError` silently and `os.dup2` the null device onto stdout,
969
1313
  so the interpreter's final flush does not print "BrokenPipeError ignored"
970
1314
  when the user quits the player. The epilog shows worked examples including
971
- `plexdo read 3 12345 | mpv --vo=drm -` for **Linux with a display attached
1315
+ `plexdo read 12345 | mpv --vo=drm -` for **Linux with a display attached
972
1316
  but no desktop environment** - describe the condition, not the machine:
973
1317
  "headless" contradicts having a screen, and the hardware could be a Pi, a
974
1318
  kiosk, a sign, or an old laptop.
975
- - `rescan [LIBRARY] [-s] [-n]` - `plex.activities` is a **property**, not a
976
- method. `section.update()` scans for files; `refresh()` only re-fetches
977
- metadata.
1319
+ - `rescan LIBRARY [-n]`, `rescan -a [-n]`, `rescan -s` - `plex.activities`
1320
+ is a **property**, not a method. `section.update()` scans for files;
1321
+ `refresh()` only re-fetches metadata. `-a/--all` scans every library with
1322
+ the one `plex.library.update()` call. Exactly one of LIBRARY and `-a` is
1323
+ required without `-s`, checked by a `check_arguments` hook so it is a
1324
+ usage error, exit 2, before anything connects: with neither, the message
1325
+ says to name a library **or use `--all` to rescan every library**, so a
1326
+ library left off by mistake does not set the whole server scanning.
1327
+ `--dry-run` says on stderr what would be scanned, and whether pending
1328
+ scans would be cancelled first. `-s` with no activity prints
1329
+ `No active scan jobs.` on stderr as a table and an **empty list** in a
1330
+ machine-readable format.
978
1331
  - `status [--section S]` - the sessions table carries `sessionKey`, `user`,
979
1332
  `library`, `ratingKey`, `title`, `state`, `player`, `platform`, `address`,
980
1333
  `progress`, and `file`; `sessionKey` is what `wait --session` takes. `records.playing_file` reports the file in use: a session marks
@@ -1174,8 +1527,9 @@ command would not repeat them and trip `duplicate-code`.
1174
1527
  none), and `--save-password` saves the password beside it so plexdo can
1175
1528
  sign in again by itself once the token stops working. `--dry-run`
1176
1529
  authenticates and saves nothing. The table says where the token went
1177
- (`secret_store.describe()`); other formats give `user`, `store`,
1178
- `verified`, and `passwordSaved`. `--password` on the command line is
1530
+ (`secret_store.describe("tokens")`); other formats give `user`, `store`,
1531
+ `passwordStore` (`describe("passwords")`), `verified`, and
1532
+ `passwordSaved`. `--password` on the command line is
1179
1533
  scrubbed from the process title via `ctypes.pythonapi.Py_GetArgcArgv`
1180
1534
  (`sys.argv` is only a copy; `ps` reads the original buffer) and redacted
1181
1535
  from `sys.argv`, with a warning that is blunt about what cannot be fixed:
@@ -1205,6 +1559,30 @@ command would not repeat them and trip `duplicate-code`.
1205
1559
  prints the exact template, which needs `RawDescriptionHelpFormatter` for the
1206
1560
  epilog's newlines; that same formatter stops argparse wrapping the
1207
1561
  description, so pre-wrap it with `textwrap.fill(..., width=78)`.
1562
+ - `write-secrets-example [-o]` - writes `constants.SECRETS_EXAMPLE` to the
1563
+ path `plex: secrets_file` names, after `expanduser`, creating its
1564
+ directory. **When `secrets_file` is not set, write nothing**: say so on
1565
+ stderr, naming the config path, and print the example to stdout, exit 0.
1566
+ A missing config file sets nothing, so it takes that path too rather than
1567
+ the "Config not found" exit; read the file with `parse_config` directly,
1568
+ not `load_config`. The default secrets path is deliberately not used:
1569
+ only a path the user chose is written to. An existing file is refused
1570
+ without `-o/--overwrite`, before `--dry-run` as for the config, because
1571
+ it may hold the only copy of a saved password or PIN. Create the file
1572
+ with `os.open(..., O_CREAT | O_EXCL, 0o600)`, unlinking an old one first
1573
+ under `-o`, so it is never readable by anyone else, even for a moment,
1574
+ and an overwritten file does not keep looser permissions. The example is
1575
+ the **password store**, the secrets file as `secret_store: file` keeps
1576
+ it: JSON as `FileStore` writes it (`indent=2`, sorted keys), with
1577
+ `passwords` and `pins`, each with one placeholder entry, and a
1578
+ `_comment` entry, a list of short lines, saying every value is a
1579
+ placeholder and that session tokens are not kept there. It must hold no
1580
+ `tokens`: a placeholder token beside a real one made the store hold
1581
+ two, so `admin_token` no longer took "the only one" and every command
1582
+ reported no admin token. JSON has no comments; `FileStore` reads only the kinds it
1583
+ is asked for and writes back every entry it loaded, so the note is
1584
+ harmless and survives a later save. Its `--help` prints the exact
1585
+ example in the epilog. Needs no server.
1208
1586
 
1209
1587
  ## ERROR HANDLING
1210
1588
 
@@ -1219,7 +1597,21 @@ or where no player answers. A
1219
1597
  command line argparse cannot parse exits 2, its own convention. An
1220
1598
  interrupted `wait` exits 130. No bare
1221
1599
  `except`; the broad ones are in `_cancel_all_scans`, the per-user copy loop,
1222
- and per-section collection, each with a `# pylint: disable=broad-except`.
1600
+ the per-library search, and per-section collection, each with a
1601
+ `# pylint: disable=broad-except`.
1602
+
1603
+ **A request that fails must not reach the user as a traceback.** `cli.main`
1604
+ wraps `_run` and catches `requests.exceptions.RequestException` and
1605
+ `plexapi.exceptions.PlexApiException`, the two families everything raised
1606
+ by a request to the server or to plex.tv belongs to, and exits 1 with one
1607
+ or two lines chosen by kind: the server could not be reached (check it is
1608
+ running, and `plex: url`), it did not answer in time, it refused the saved
1609
+ token (run `plexdo login`), or it refused the request. Only the **first
1610
+ line** of the error is shown, since plexapi attaches the server's whole
1611
+ HTML error page to some, and it goes through `security.redact`, since a
1612
+ URL in a requests error can carry a token. The traceback is logged at
1613
+ DEBUG. Handlers closer to the request, such as the per-user loops and
1614
+ `UserAccessError`, still take precedence; this is the net under them.
1223
1615
 
1224
1616
  ## PLATFORM SUPPORT
1225
1617
 
@@ -1247,6 +1639,40 @@ data. All cover the same values, read a 15-minute cache under the platform
1247
1639
  cache directory (honouring `cache_dir`), and refresh a stale cache in the
1248
1640
  background so completion never blocks.
1249
1641
 
1642
+ **Each script finds the cache as plexdo does**, or completion silently
1643
+ offers nothing: `plex: cache_dir` from `config.yaml`, in
1644
+ `$XDG_CONFIG_HOME/plexdo` or `~/.config/plexdo` (`%LOCALAPPDATA%\PlexDo` on
1645
+ Windows), with environment variables and a leading `~` expanded; else
1646
+ `$XDG_CACHE_HOME/plexdo`, by default `~/.cache/plexdo`. An XDG variable
1647
+ counts only when absolute. They once read the 1.x INI file and a
1648
+ hard-coded `~/.cache`, long after both had moved. PyYAML may not be
1649
+ importable by whichever python the script finds, and PowerShell has no
1650
+ YAML reader, so each reads the one scalar it needs a line at a time: a
1651
+ `cache_dir:` line indented under the top-level `plex:` key, skipping
1652
+ blank and comment lines, unquoting a double-quoted value as a JSON string
1653
+ and a single-quoted one by its doubled quotes, and cutting a trailing
1654
+ ` #` comment from a plain one. bash, zsh, and fish share one python
1655
+ snippet, which must contain **no single quote and no backslash**, because
1656
+ fish holds it in a single-quoted string.
1657
+
1658
+ **The cache is kept twice, by ID and by word.** `titles.LIBRARY.json` and
1659
+ `playlists.USER.json` are named for the numeric ID, but a script holds
1660
+ whatever is on the command line, which may be a title, and cannot resolve
1661
+ it as plexdo does. So `cache.write_word_caches` files the same rows as
1662
+ `titles-by-word.WORD.json` and `playlists-by-word.WORD.json`, once for
1663
+ each spelling: the ID, the title, and the word as typed.
1664
+ `identify.remember_words` records the three while the argument is
1665
+ resolved, on `args.argument_words`, since resolution replaces what was
1666
+ typed with the ID; `argument_words(args, name)` reads them back. Both
1667
+ caches are written whenever a result is handled, so they cannot drift. In
1668
+ a file name `cache.cache_key` replaces each of `/ \ : * ? " < > |` and
1669
+ the control characters with `_`, chosen so every shell can make the same
1670
+ name with one substitution of its own; each script has that function and
1671
+ a test compares them. Playlist and album-name completion read the by-word
1672
+ cache for the word typed, dequoted; ratingKey completion still gathers
1673
+ every `titles.*.json`, which the by-word names do not match, so no key is
1674
+ offered twice.
1675
+
1250
1676
  Numeric IDs are shown with their titles. Distinguish what is *insertable* from
1251
1677
  what is merely *shown*: user and library titles are insertable because those
1252
1678
  arguments accept them, but an item title is not, since `fetchItem` needs a
@@ -1268,7 +1694,19 @@ numeric ratingKey.
1268
1694
  skipping options that consume a value. Guard the background refresh on the
1269
1695
  binary existing: fish reports an unknown command *before* redirection
1270
1696
  applies, scribbling on the prompt mid-completion.
1271
- - **PowerShell** - `Register-ArgumentCompleter -Native`. Needs no external
1697
+ - For `playlist-build` and `playlist-copy`, every script first finds the mode
1698
+ flag among the words already typed, and completes each positional as that
1699
+ mode defines it: with `-I` or `-C` a name then rating keys; with `-R` a
1700
+ user, then that user's playlists; with `-A` on `playlist-build` a user,
1701
+ that user's playlists, then rating keys; with `-T` on `playlist-copy` the
1702
+ receiving user third. With no mode typed yet, nothing says what an
1703
+ argument is, so bash offers the mode flags and the others offer only
1704
+ options. In zsh, hold option specs that use `{-o,--overwrite}` braces in
1705
+ **arrays**: a scalar assignment does not expand the braces, and the spec
1706
+ reaches `_arguments` malformed and is silently never offered.
1707
+ - **PowerShell** - `Register-ArgumentCompleter -Native`. Compare the mode
1708
+ flags with `-cin`: PowerShell's `-in` ignores case, and `-a` and `-A` are
1709
+ different options. Needs no external
1272
1710
  interpreter: `ConvertFrom-Json` reads the cache. Return `CompletionResult`
1273
1711
  objects so the ID is inserted and the title shows as a tooltip. Two traps
1274
1712
  that parse cleanly and fail at runtime: a range of `0..-1` **wraps** and
@@ -1278,7 +1716,8 @@ numeric ratingKey.
1278
1716
  ## MAN PAGE
1279
1717
 
1280
1718
  `man/plexdo.1`, in roff, mirrored into `src/plexdo/data/`. Documents every
1281
- command, the configuration and token formats, exit status, environment, files,
1719
+ command, under the same five headings and in the same order as `--help`,
1720
+ and the configuration and token formats, exit status, environment, files,
1282
1721
  and worked examples, and passes `groff -man -Tutf8 -ww -z` with no warnings.
1283
1722
 
1284
1723
  Escapes that silently corrupt copy-pasteable examples: a literal backslash is
@@ -1305,9 +1744,16 @@ double-width characters, the ASCII fallback, and fitting to a budget;
1305
1744
  identifier resolution including the ID-versus-title collision; the overwrite
1306
1745
  guard performing **no** server calls when it refuses; watched-state selection
1307
1746
  in both directions and that an undated state never wins; which playlist entries
1308
- `clean-playlist` drops, and that it numbers them by their place in the
1309
- playlist rather than within the removal list; that concatenation preserves the
1310
- order given and that `--unique` matches on ratingKey; that a per-user create
1747
+ `playlist-clean` drops, and that it numbers them by their place in the
1748
+ playlist rather than within the removal list; that a merge keeps each side's
1749
+ order and that `--unique` matches on ratingKey; that every command is in
1750
+ exactly one help group, that `--help` lists them grouped and sorted, and that
1751
+ the old names are refused; that each `playlist-build` and `playlist-copy`
1752
+ mode names its arguments, and that the wrong count, a missing or second mode
1753
+ flag, and `--append` with a creating option all exit 2; that
1754
+ `write-secrets-example` prints rather than writes when `secrets_file` is not
1755
+ set, refuses an existing file, writes mode 0600, and that its example loads
1756
+ through `FileStore` with its note surviving a save; that a per-user create
1311
1757
  reports a taken name instead of exiting and performs no call when it does, and
1312
1758
  that a fan-out records an unreachable user and an unexpected failure and keeps
1313
1759
  going; where `wait` puts its end point for every combination of marker,
@@ -1359,9 +1805,10 @@ because unknown and zero are different things.
1359
1805
  packaged mirrors, and that no source file contains non-ASCII. Editing the
1360
1806
  source copy and forgetting the mirror ships a stale asset in the wheel.
1361
1807
  - `smoke` imports the package, builds the full parser, and asserts every
1362
- module in `MODULES` still exposes `register()` and `COMMANDS`. pylint scores
1808
+ module in `MODULES` still exposes `register()` and `COMMANDS`, and that
1809
+ the commands registered are exactly those in `GROUPS`. pylint scores
1363
1810
  a module 10.00/10 even when an edit has truncated that tail, which breaks
1364
- every command in it.
1811
+ every command in it; a count to compare against would only go stale.
1365
1812
 
1366
1813
  The Makefile also provides `install`, `uninstall`, `develop`, `reinstall`,
1367
1814
  `install-man`, `uninstall-man`, `install-completion` and its four per-shell
@@ -1408,8 +1855,9 @@ version cannot be undone. Strip a leading `v` before comparing.
1408
1855
  ## COMMAND PAGES
1409
1856
 
1410
1857
  Beside the roff overview, every command has a detailed page in scdoc source
1411
- format: `man/plexdo-COMMAND.1.scd`, one per command, with `list-library`
1412
- documented in the `list-titles` page as the alias it is, and
1858
+ format: `man/plexdo-COMMAND.1.scd`, one per command, so `playlist-build`
1859
+ and `playlist-copy` each have one page covering every mode, with a `MODES`
1860
+ section and each argument marked with the modes that take it, and
1413
1861
  `man/plexdo-conventions.7.scd` for what every command shares - the global
1414
1862
  options, how users, libraries, playlists, items, and sessions are named,
1415
1863
  output formats, path rewriting, pacing, exit status, files, and
@@ -1448,7 +1896,7 @@ and SEE ALSO. GLOBAL OPTIONS says what **each** global flag does for that
1448
1896
  particular command - "no effect" is an answer, and `--dry-run` writing a
1449
1897
  file anyway is worth saying. OPTION INTERACTIONS covers every conflict and
1450
1898
  dependency, whether argparse enforces it (`-u`/`-a`) or the handler does
1451
- (`find-missing -A` with SHOW, `wait --now` without `-r`). Document what the
1899
+ (`missing-find -A` with SHOW, `wait --now` without `-r`). Document what the
1452
1900
  code does, checked against it, not what a help string implies. Sample tables
1453
1901
  are real output from `print_table` with an ASCII console, never hand-drawn.
1454
1902