plex-tui 0.3.28__tar.gz → 0.3.35__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 (52) hide show
  1. {plex_tui-0.3.28 → plex_tui-0.3.35}/AGENTS.md +24 -2
  2. {plex_tui-0.3.28 → plex_tui-0.3.35}/CHANGELOG.md +16 -0
  3. {plex_tui-0.3.28 → plex_tui-0.3.35}/Makefile +5 -1
  4. {plex_tui-0.3.28 → plex_tui-0.3.35}/PACKAGING.md +5 -0
  5. {plex_tui-0.3.28 → plex_tui-0.3.35}/PKG-INFO +5 -1
  6. {plex_tui-0.3.28 → plex_tui-0.3.35}/README.md +4 -0
  7. {plex_tui-0.3.28 → plex_tui-0.3.35}/RELEASE.md +8 -5
  8. {plex_tui-0.3.28 → plex_tui-0.3.35}/packaging/aur/.SRCINFO +3 -3
  9. {plex_tui-0.3.28 → plex_tui-0.3.35}/packaging/aur/PKGBUILD +2 -2
  10. {plex_tui-0.3.28 → plex_tui-0.3.35}/pyproject.toml +1 -1
  11. plex_tui-0.3.35/scripts/stage_release.py +215 -0
  12. {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/__init__.py +1 -1
  13. {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/app.py +25 -12
  14. plex_tui-0.3.35/src/plextui/auth.py +204 -0
  15. {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/plex_service.py +36 -11
  16. {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_app_helpers.py +9 -5
  17. plex_tui-0.3.35/tests/test_auth.py +145 -0
  18. {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_plex_service.py +20 -1
  19. {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_release_checks.py +91 -0
  20. plex_tui-0.3.28/src/plextui/auth.py +0 -94
  21. plex_tui-0.3.28/tests/test_auth.py +0 -30
  22. {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/aur.yml +0 -0
  23. {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/bump.yml +0 -0
  24. {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/ci.yml +0 -0
  25. {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/post-release-aur.yml +0 -0
  26. {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/post-release-homebrew.yml +0 -0
  27. {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/publish-aur.yml +0 -0
  28. {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/publish-pypi.yml +0 -0
  29. {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/publish-testpypi.yml +0 -0
  30. {plex_tui-0.3.28 → plex_tui-0.3.35}/.gitignore +0 -0
  31. {plex_tui-0.3.28 → plex_tui-0.3.35}/LICENSE +0 -0
  32. {plex_tui-0.3.28 → plex_tui-0.3.35}/ROADMAP.md +0 -0
  33. {plex_tui-0.3.28 → plex_tui-0.3.35}/config.example.toml +0 -0
  34. {plex_tui-0.3.28 → plex_tui-0.3.35}/docs/assets/grid-view.png +0 -0
  35. {plex_tui-0.3.28 → plex_tui-0.3.35}/docs/assets/list-view.png +0 -0
  36. {plex_tui-0.3.28 → plex_tui-0.3.35}/docs/plex-integration-research.md +0 -0
  37. {plex_tui-0.3.28 → plex_tui-0.3.35}/packaging/aur/README.md +0 -0
  38. {plex_tui-0.3.28 → plex_tui-0.3.35}/packaging/homebrew/README.md +0 -0
  39. {plex_tui-0.3.28 → plex_tui-0.3.35}/scripts/check_release.py +0 -0
  40. {plex_tui-0.3.28 → plex_tui-0.3.35}/scripts/update_aur_package.py +0 -0
  41. {plex_tui-0.3.28 → plex_tui-0.3.35}/scripts/update_homebrew_formula.py +0 -0
  42. {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/__main__.py +0 -0
  43. {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/artwork.py +0 -0
  44. {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/config.py +0 -0
  45. {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/models.py +0 -0
  46. {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/player.py +0 -0
  47. {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/smoke.py +0 -0
  48. {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_app_navigation.py +0 -0
  49. {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_artwork.py +0 -0
  50. {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_cli.py +0 -0
  51. {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_config.py +0 -0
  52. {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_player.py +0 -0
@@ -87,8 +87,8 @@ contracts for these durable areas:
87
87
  config/auth, mpv playback, and smoke entry points.
88
88
  - `tests/`: pytest coverage for app helpers/navigation, service mapping,
89
89
  config/auth/player/artwork behavior, and release workflow checks.
90
- - `scripts/`: release/package maintenance scripts used by checks and
91
- post-release automation.
90
+ - `scripts/`: release/package maintenance scripts used by checks, deterministic
91
+ release staging, and post-release automation.
92
92
  - `packaging/`: Homebrew and AUR notes plus source AUR metadata.
93
93
  - `docs/`: research notes and README visual assets.
94
94
  - Root docs and config examples: README, PACKAGING, RELEASE, ROADMAP,
@@ -126,6 +126,7 @@ make test # run pytest
126
126
  make compile # compile src and tests
127
127
  make build # build sdist and wheel
128
128
  make check-package # build and validate package metadata
129
+ make stage-release # stage version and changelog updates for next release
129
130
  make check # smoke, tests, compile, and package validation
130
131
  ```
131
132
 
@@ -168,6 +169,11 @@ Git history uses short imperative subjects, such as `Speed up grid navigation`
168
169
  and `Polish settings and grid navigation`. Keep commits scoped to one logical
169
170
  change and include docs/tests with behavior changes.
170
171
 
172
+ For behavior changes that users or future release notes should know about, add
173
+ or update the `CHANGELOG.md` `Unreleased` section as part of the work. Do not
174
+ leave release-note reconstruction to the release staging step unless the change
175
+ is intentionally internal-only and that reason is clear in the PR.
176
+
171
177
  Pull requests should include:
172
178
 
173
179
  - A concise summary of user-visible behavior.
@@ -199,6 +205,14 @@ only local tags or the latest GitHub Release. Non-release PRs with `#patch`,
199
205
  `#minor`, or `#major` still create tags, so release prep files should match the
200
206
  tag that the release PR merge will create.
201
207
 
208
+ Use `make stage-release BUMP=patch`, `BUMP=minor`, or `BUMP=major` to stage a
209
+ planned release. Agents must use this script-backed target for normal release
210
+ prep instead of hand-editing version files and moving changelog entries. The
211
+ target fetches tags, chooses the next version from the latest semver tag, updates
212
+ `pyproject.toml`, `src/plextui/__init__.py`, and `CHANGELOG.md`, and fails if
213
+ `CHANGELOG.md` has no `Unreleased` notes to release. After staging, run
214
+ `make check-release` and the relevant validation before opening the release PR.
215
+
202
216
  GitHub Release publishing is controlled by release markers in PR titles only:
203
217
  `#release`, `#publish`, or `#ship`. Keep those markers out of ordinary PR titles
204
218
  unless the merge should publish PyPI, Homebrew, and AUR. PR bodies may mention
@@ -220,3 +234,11 @@ GitHub CLI notes:
220
234
  Never commit real Plex tokens, account tokens, debug logs, or local config files.
221
235
  Use `config.example.toml` for examples. Logs should redact tokens; preserve that
222
236
  behavior when changing playback or request diagnostics.
237
+
238
+
239
+ ## Optional Local Tooling
240
+
241
+ If `rtk` is installed, it can be used to reduce noisy command output while
242
+ preserving command behavior, for example `rtk git status`, `rtk git diff`, or
243
+ `rtk pytest tests/`. Do not require `rtk` for repository work; fall back to the
244
+ normal command when it is unavailable or when raw debugging output is needed.
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.35 - 2026-06-15
6
+
7
+ - Documented Homebrew 6 tap trust requirements for installing from the
8
+ `so1omon563/plex-tui` tap.
9
+ - Verified Plex server reachability during browser login before saving a server
10
+ URL, so first-run auth no longer offers endpoints that cannot be reached from
11
+ the current machine.
12
+
13
+ ## 0.3.31 - 2026-06-15
14
+
15
+ - Polished media type labels so list and grid rows describe Plex media kinds
16
+ more clearly.
17
+ - Improved first-run Plex server selection by preferring usable local/private
18
+ connection URLs, labeling connection types, marking the recommended endpoint,
19
+ and adding relogin recovery guidance for unreachable saved URLs.
20
+
5
21
  ## 0.3.28 - 2026-06-13
6
22
 
7
23
  - Added a DOX-aligned project agent model and documented repository operating
@@ -1,6 +1,7 @@
1
- .PHONY: install-dev run test compile smoke build check-package check-release check clean
1
+ .PHONY: install-dev run test compile smoke build check-package check-release stage-release check clean
2
2
 
3
3
  PYTHON ?= .venv/bin/python
4
+ BUMP ?= patch
4
5
 
5
6
  install-dev:
6
7
  $(PYTHON) -m pip install -e ".[dev]"
@@ -26,6 +27,9 @@ check-package: build
26
27
  check-release:
27
28
  $(PYTHON) scripts/check_release.py
28
29
 
30
+ stage-release:
31
+ $(PYTHON) scripts/stage_release.py --bump $(BUMP)
32
+
29
33
  check: smoke test compile check-release check-package
30
34
 
31
35
  clean:
@@ -45,6 +45,7 @@ https://github.com/so1omon563/homebrew-plex-tui
45
45
  User install:
46
46
 
47
47
  ```bash
48
+ brew trust --tap so1omon563/plex-tui
48
49
  brew tap so1omon563/plex-tui
49
50
  brew install plex-tui
50
51
  ```
@@ -52,6 +53,10 @@ brew install plex-tui
52
53
  The formula depends on `mpv` and `python@3.13`, then installs the Python app in
53
54
  a Homebrew-managed virtualenv. The first install can take several minutes
54
55
  because native Python resources such as `pillow` are built from source.
56
+ Homebrew 6 requires non-official taps to be trusted before Homebrew loads
57
+ formulae from them. The `plex-tui` formula only uses Homebrew/core formula
58
+ dependencies, so users do not need to trust any additional taps for `mpv`,
59
+ `python@3.13`, or the bundled Python resources.
55
60
 
56
61
  Validation commands:
57
62
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plex-tui
3
- Version: 0.3.28
3
+ Version: 0.3.35
4
4
  Summary: A small standalone Plex terminal UI.
5
5
  Project-URL: Homepage, https://github.com/so1omon563/plex-tui
6
6
  Project-URL: Issues, https://github.com/so1omon563/plex-tui/issues
@@ -101,6 +101,7 @@ or follow the pipx installation guide.
101
101
  ### Homebrew
102
102
 
103
103
  ```bash
104
+ brew trust --tap so1omon563/plex-tui
104
105
  brew tap so1omon563/plex-tui
105
106
  brew install plex-tui
106
107
  plex-tui --smoke
@@ -109,6 +110,9 @@ plex-tui --smoke
109
110
  The Homebrew formula installs `mpv` automatically. The first install can take
110
111
  several minutes because native Python dependencies such as `pillow` are built
111
112
  from source.
113
+ Homebrew 6 requires non-official taps to be trusted before Homebrew loads
114
+ formulae from them. `plex-tui` only depends on formulae from Homebrew/core, so no
115
+ additional tap trust is required for its dependencies.
112
116
 
113
117
  ### Arch Linux
114
118
 
@@ -65,6 +65,7 @@ or follow the pipx installation guide.
65
65
  ### Homebrew
66
66
 
67
67
  ```bash
68
+ brew trust --tap so1omon563/plex-tui
68
69
  brew tap so1omon563/plex-tui
69
70
  brew install plex-tui
70
71
  plex-tui --smoke
@@ -73,6 +74,9 @@ plex-tui --smoke
73
74
  The Homebrew formula installs `mpv` automatically. The first install can take
74
75
  several minutes because native Python dependencies such as `pillow` are built
75
76
  from source.
77
+ Homebrew 6 requires non-official taps to be trusted before Homebrew loads
78
+ formulae from them. `plex-tui` only depends on formulae from Homebrew/core, so no
79
+ additional tap trust is required for its dependencies.
76
80
 
77
81
  ### Arch Linux
78
82
 
@@ -20,9 +20,9 @@ make check
20
20
 
21
21
  Prepare a release PR:
22
22
 
23
- - Update `version` in `pyproject.toml`.
24
- - Update `src/plextui/__init__.py`.
25
- - Move `CHANGELOG.md` entries from `Unreleased` to the release version and date.
23
+ - Confirm `CHANGELOG.md` has accurate `Unreleased` entries for the changes being
24
+ released. Add or revise those notes before staging the release.
25
+ - Run `make stage-release BUMP=patch`, `BUMP=minor`, or `BUMP=major`.
26
26
  - Confirm `README.md`, `PACKAGING.md`, and `config.example.toml` match current behavior.
27
27
  - Confirm the Git remote points to `https://github.com/so1omon563/plex-tui`.
28
28
  - Make sure the PR title or body includes the right bump marker:
@@ -31,8 +31,11 @@ Prepare a release PR:
31
31
  GitHub Release and publish to PyPI.
32
32
 
33
33
  The semver bumper creates tags from merge metadata, but it does not edit project
34
- files. Keep the version files and changelog in the PR aligned with the tag the
35
- merge will create.
34
+ files. `make stage-release` fetches tags, chooses the next version from the
35
+ latest semver tag, updates `pyproject.toml` and `src/plextui/__init__.py`, and
36
+ moves `CHANGELOG.md` `Unreleased` entries into a dated version section. It fails
37
+ when `Unreleased` is empty so release notes are fixed before the release PR is
38
+ opened. Keep the staged files aligned with the tag the merge will create.
36
39
 
37
40
  Do not update Homebrew or AUR checksums in the release PR. Those checksums depend
38
41
  on the tag or published artifact that does not exist until after merge. Handle
@@ -1,6 +1,6 @@
1
1
  pkgbase = plex-tui
2
2
  pkgdesc = Standalone Plex terminal UI with mpv playback
3
- pkgver = 0.3.20
3
+ pkgver = 0.3.31
4
4
  pkgrel = 1
5
5
  url = https://github.com/so1omon563/plex-tui
6
6
  arch = any
@@ -16,7 +16,7 @@ pkgbase = plex-tui
16
16
  depends = python-plexapi
17
17
  depends = python-rich
18
18
  depends = python-textual
19
- source = plex-tui-0.3.20.tar.gz::https://github.com/so1omon563/plex-tui/archive/refs/tags/v0.3.20.tar.gz
20
- sha256sums = 2e550a37c89b958d6eb4744cbd847fa3bbc7b202dc15ed63d1f22c787767f9b9
19
+ source = plex-tui-0.3.31.tar.gz::https://github.com/so1omon563/plex-tui/archive/refs/tags/v0.3.31.tar.gz
20
+ sha256sums = 89cf8080c48c7d57a0bf9f4e6b273e9842fef7ecfdf783bf3add6c2425a30569
21
21
 
22
22
  pkgname = plex-tui
@@ -1,6 +1,6 @@
1
1
  # Maintainer: so1omon
2
2
  pkgname=plex-tui
3
- pkgver=0.3.20
3
+ pkgver=0.3.31
4
4
  pkgrel=1
5
5
  pkgdesc="Standalone Plex terminal UI with mpv playback"
6
6
  arch=("any")
@@ -22,7 +22,7 @@ makedepends=(
22
22
  "python-wheel"
23
23
  )
24
24
  source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
25
- sha256sums=("2e550a37c89b958d6eb4744cbd847fa3bbc7b202dc15ed63d1f22c787767f9b9")
25
+ sha256sums=("89cf8080c48c7d57a0bf9f4e6b273e9842fef7ecfdf783bf3add6c2425a30569")
26
26
 
27
27
  build() {
28
28
  cd "$pkgname-$pkgver"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "plex-tui"
7
- version = "0.3.28"
7
+ version = "0.3.35"
8
8
  description = "A small standalone Plex terminal UI."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ """Stage deterministic release metadata updates."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import datetime as dt
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import tomllib
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+
16
+ VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
17
+ TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
18
+
19
+
20
+ @dataclass(frozen=True, order=True)
21
+ class Version:
22
+ major: int
23
+ minor: int
24
+ patch: int
25
+
26
+ @classmethod
27
+ def parse(cls, value: str) -> Version:
28
+ if not VERSION_RE.fullmatch(value):
29
+ raise ValueError(f"invalid version {value!r}; expected X.Y.Z")
30
+ major, minor, patch = [int(part) for part in value.split(".")]
31
+ return cls(major, minor, patch)
32
+
33
+ @classmethod
34
+ def parse_tag(cls, value: str) -> Version | None:
35
+ match = TAG_RE.fullmatch(value)
36
+ if not match:
37
+ return None
38
+ major, minor, patch = [int(part) for part in match.groups()]
39
+ return cls(major, minor, patch)
40
+
41
+ def bump(self, kind: str) -> Version:
42
+ if kind == "major":
43
+ return Version(self.major + 1, 0, 0)
44
+ if kind == "minor":
45
+ return Version(self.major, self.minor + 1, 0)
46
+ if kind == "patch":
47
+ return Version(self.major, self.minor, self.patch + 1)
48
+ raise ValueError(f"unsupported bump kind {kind!r}")
49
+
50
+ def __str__(self) -> str:
51
+ return f"{self.major}.{self.minor}.{self.patch}"
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> int:
55
+ parser = argparse.ArgumentParser(description=__doc__)
56
+ parser.add_argument("--root", type=Path, default=Path.cwd(), help="repository root")
57
+ parser.add_argument("--bump", choices=["patch", "minor", "major"], default="patch", help="semver bump from latest tag")
58
+ parser.add_argument("--version", help="explicit X.Y.Z version to stage")
59
+ parser.add_argument("--date", default=dt.date.today().isoformat(), help="release date in YYYY-MM-DD format")
60
+ parser.add_argument("--no-fetch", action="store_true", help="use local tags without fetching origin first")
61
+ args = parser.parse_args(argv)
62
+
63
+ root = args.root.resolve()
64
+ try:
65
+ stage_release(root, args.bump, args.version, args.date, fetch_tags=not args.no_fetch)
66
+ except ReleaseStageError as exc:
67
+ print(f"error: {exc}", file=sys.stderr)
68
+ return 1
69
+ return 0
70
+
71
+
72
+ class ReleaseStageError(RuntimeError):
73
+ pass
74
+
75
+
76
+ def stage_release(root: Path, bump: str, explicit_version: str | None, release_date: str, *, fetch_tags: bool) -> None:
77
+ validate_release_date(release_date)
78
+ ensure_clean_worktree(root)
79
+ if fetch_tags:
80
+ run_git(root, ["fetch", "--tags", "origin"])
81
+
82
+ latest = latest_tag_version(root)
83
+ if latest is None:
84
+ raise ReleaseStageError("no semver tags found; expected tags like v0.3.31")
85
+
86
+ version = Version.parse(explicit_version) if explicit_version else latest.bump(bump)
87
+ if version <= latest:
88
+ raise ReleaseStageError(f"target version {version} must be newer than latest tag v{latest}")
89
+ if tag_exists(root, version):
90
+ raise ReleaseStageError(f"tag v{version} already exists")
91
+
92
+ current = Version.parse(read_pyproject_version(root / "pyproject.toml"))
93
+ if current >= version:
94
+ raise ReleaseStageError(f"project metadata is {current}, but target release is {version}")
95
+
96
+ ensure_changelog_ready(root / "CHANGELOG.md")
97
+ update_pyproject_version(root / "pyproject.toml", version)
98
+ update_init_version(root / "src/plextui/__init__.py", version)
99
+ update_changelog(root / "CHANGELOG.md", version, release_date)
100
+
101
+ print(f"staged release {version}")
102
+ print(f"PR title: Prepare release {version} #{bump} #release")
103
+
104
+
105
+ def validate_release_date(value: str) -> None:
106
+ try:
107
+ dt.date.fromisoformat(value)
108
+ except ValueError as exc:
109
+ raise ReleaseStageError(f"invalid release date {value!r}; expected YYYY-MM-DD") from exc
110
+
111
+
112
+ def ensure_clean_worktree(root: Path) -> None:
113
+ completed = run_git(root, ["status", "--porcelain"], capture=True)
114
+ if completed.stdout.strip():
115
+ raise ReleaseStageError("working tree must be clean before staging a release")
116
+
117
+
118
+ def latest_tag_version(root: Path) -> Version | None:
119
+ completed = run_git(root, ["tag", "--list", "v[0-9]*"], capture=True)
120
+ versions = [version for tag in completed.stdout.splitlines() if (version := Version.parse_tag(tag.strip()))]
121
+ if not versions:
122
+ return None
123
+ return max(versions)
124
+
125
+
126
+ def tag_exists(root: Path, version: Version) -> bool:
127
+ completed = run_git(root, ["tag", "--list", f"v{version}"], capture=True)
128
+ return bool(completed.stdout.strip())
129
+
130
+
131
+ def run_git(root: Path, args: list[str], *, capture: bool = False) -> subprocess.CompletedProcess[str]:
132
+ try:
133
+ return subprocess.run(
134
+ ["git", *args],
135
+ cwd=root,
136
+ check=True,
137
+ capture_output=capture,
138
+ text=True,
139
+ )
140
+ except subprocess.CalledProcessError as exc:
141
+ output = ((exc.stdout or "") + (exc.stderr or "")).strip()
142
+ message = f"git {' '.join(args)} failed"
143
+ if output:
144
+ message = f"{message}: {output}"
145
+ raise ReleaseStageError(message) from exc
146
+
147
+
148
+ def read_pyproject_version(path: Path) -> str:
149
+ try:
150
+ with path.open("rb") as fh:
151
+ data = tomllib.load(fh)
152
+ except (OSError, tomllib.TOMLDecodeError) as exc:
153
+ raise ReleaseStageError(f"read {path}: {exc}") from exc
154
+ version = data.get("project", {}).get("version")
155
+ if not isinstance(version, str) or not version:
156
+ raise ReleaseStageError(f"{path} missing project.version")
157
+ return version
158
+
159
+
160
+ def update_pyproject_version(path: Path, version: Version) -> None:
161
+ text = read_text(path)
162
+ updated, count = re.subn(r'(?m)^version = "[^"]+"$', f'version = "{version}"', text, count=1)
163
+ if count != 1:
164
+ raise ReleaseStageError(f"{path} must contain exactly one project version assignment")
165
+ path.write_text(updated, encoding="utf-8")
166
+
167
+
168
+ def update_init_version(path: Path, version: Version) -> None:
169
+ text = read_text(path)
170
+ updated, count = re.subn(r'(?m)^__version__ = "[^"]+"$', f'__version__ = "{version}"', text, count=1)
171
+ if count != 1:
172
+ raise ReleaseStageError(f"{path} must contain exactly one __version__ assignment")
173
+ path.write_text(updated, encoding="utf-8")
174
+
175
+
176
+ def ensure_changelog_ready(path: Path) -> None:
177
+ read_unreleased_entries(path)
178
+
179
+
180
+ def update_changelog(path: Path, version: Version, release_date: str) -> None:
181
+ text, content_start, next_start, unreleased = read_unreleased_entries(path)
182
+ before = text[: text.find("## Unreleased")]
183
+ after = text[next_start:]
184
+ section = f"## Unreleased\n\n## {version} - {release_date}\n\n{unreleased}\n\n"
185
+ path.write_text(before + section + after, encoding="utf-8")
186
+
187
+
188
+ def read_unreleased_entries(path: Path) -> tuple[str, int, int, str]:
189
+ text = read_text(path)
190
+ header = "## Unreleased"
191
+ start = text.find(header)
192
+ if start == -1:
193
+ raise ReleaseStageError("CHANGELOG.md must contain an Unreleased section")
194
+
195
+ content_start = start + len(header)
196
+ next_match = re.search(r"(?m)^## \d+\.\d+\.\d+ - \d{4}-\d{2}-\d{2}$", text[content_start:])
197
+ if next_match is None:
198
+ raise ReleaseStageError("CHANGELOG.md must contain a dated release section after Unreleased")
199
+
200
+ next_start = content_start + next_match.start()
201
+ unreleased = text[content_start:next_start].strip()
202
+ if not unreleased:
203
+ raise ReleaseStageError("CHANGELOG.md Unreleased section is empty; add release notes before staging")
204
+ return text, content_start, next_start, unreleased
205
+
206
+
207
+ def read_text(path: Path) -> str:
208
+ try:
209
+ return path.read_text(encoding="utf-8")
210
+ except OSError as exc:
211
+ raise ReleaseStageError(f"read {path}: {exc}") from exc
212
+
213
+
214
+ if __name__ == "__main__":
215
+ sys.exit(main())
@@ -1,3 +1,3 @@
1
1
  """Plex TUI prototype."""
2
2
 
3
- __version__ = "0.3.28"
3
+ __version__ = "0.3.35"
@@ -63,7 +63,7 @@ from .player import (
63
63
  subtitle_choices,
64
64
  transcode_quality_label,
65
65
  )
66
- from .plex_service import PlexService, media_details, row_progress_marker
66
+ from .plex_service import PlexService, kind_label, media_details, row_progress_marker
67
67
  GRID_CARD_GAP = 2
68
68
  GRID_DENSITY_SPECS = {
69
69
  "compact": {"width": 18, "content_width": 15, "art_width": 14, "art_height": 7, "height": 10, "max_columns": 6},
@@ -125,7 +125,7 @@ class LibraryMenuRow(ListItem):
125
125
  class MediaRow(ListItem):
126
126
  def __init__(self, media: MediaItem) -> None:
127
127
  marker = "▶" if media.playable else "›"
128
- subtitle = f" [{media.kind}] {media.subtitle}".rstrip()
128
+ subtitle = f" [{kind_label(media.kind)}] {media.subtitle}".rstrip()
129
129
  progress = row_progress_marker(media.raw)
130
130
  progress_text = f" {progress}" if progress else ""
131
131
  self.label_text = f"{marker} {media.title}{subtitle}{progress_text}"
@@ -286,8 +286,10 @@ class LoadMoreRow(ListItem):
286
286
 
287
287
 
288
288
  class ServerRow(ListItem):
289
- def __init__(self, choice: ServerChoice) -> None:
290
- super().__init__(Label(f"{choice.name} {choice.uri}"))
289
+ def __init__(self, choice: ServerChoice, is_recommended: bool = False) -> None:
290
+ marker = "* " if is_recommended else " "
291
+ suffix = " (recommended)" if is_recommended else ""
292
+ super().__init__(Label(f"{marker}{choice.name} {choice.uri} [{choice.row_label}]{suffix}"))
291
293
  self.choice = choice
292
294
 
293
295
 
@@ -668,11 +670,11 @@ class PlexTuiApp(App[None]):
668
670
  self.set_media_title("Select Server")
669
671
  view = self.show_media_list()
670
672
  view.clear()
671
- for choice in choices:
672
- view.append(ServerRow(choice))
673
+ for index, choice in enumerate(choices):
674
+ view.append(ServerRow(choice, is_recommended=index == 0))
673
675
  view.focus()
674
- self.show_detail_text("Choose the connection you want this app to use.")
675
- self.set_status("Select a Plex server connection and press Enter")
676
+ self.show_detail_text("Choose the reachable connection you want this app to use. The first option is the recommended starting point.")
677
+ self.set_status("Select a reachable Plex server connection and press Enter")
676
678
 
677
679
  self.call_from_thread(show_choices)
678
680
 
@@ -739,7 +741,17 @@ class PlexTuiApp(App[None]):
739
741
  self.set_status(context_hint(row))
740
742
  elif isinstance(row, ServerRow):
741
743
  mark_active_row(event.list_view, row)
742
- self.show_detail_text(f"{row.choice.name}\n\n{row.choice.uri}\n\nSource: {row.choice.source}")
744
+ self.show_detail_text(
745
+ "\n".join(
746
+ [
747
+ row.choice.name,
748
+ "",
749
+ row.choice.uri,
750
+ f"Type: {row.choice.connection_label}",
751
+ f"Source: {row.choice.source}",
752
+ ]
753
+ )
754
+ )
743
755
  self.set_status(context_hint(row))
744
756
  elif isinstance(row, StreamRow):
745
757
  mark_active_row(event.list_view, row)
@@ -2282,12 +2294,13 @@ class PlexTuiApp(App[None]):
2282
2294
 
2283
2295
  def show_error(self, text: str) -> None:
2284
2296
  config_hint = f"Config: {config_path()}"
2297
+ recovery_hint = "Use Settings > Plex > Relogin with Plex to try another server URL."
2285
2298
  self.set_status(f"Error: {text}")
2286
2299
  self.set_media_title("Error")
2287
2300
  view = self.show_media_list()
2288
2301
  view.clear()
2289
- view.append(ListItem(Label(f"{text}\n{config_hint}")))
2290
- self.show_detail_text(config_hint)
2302
+ view.append(ListItem(Label(f"{text}\n{config_hint}\n{recovery_hint}")))
2303
+ self.show_detail_text(f"{config_hint}\n{recovery_hint}")
2291
2304
 
2292
2305
  def show_playback_error(self, text: str) -> None:
2293
2306
  self.detail_refresh_token += 1
@@ -2481,7 +2494,7 @@ def render_media_grid_card(
2481
2494
  title_style = "bold #e5a00d" if selected else "bold"
2482
2495
  card_width = grid_card_width(config)
2483
2496
  title = grid_card_text(media.title, config)
2484
- subtitle = grid_card_text(" ".join(bit for bit in (media.kind, media.subtitle) if bit), config)
2497
+ subtitle = grid_card_text(" ".join(bit for bit in (kind_label(media.kind), media.subtitle) if bit), config)
2485
2498
  artwork = artwork_overrides.get(media.key) if artwork_overrides is not None else None
2486
2499
  artwork = copy_renderable(artwork)
2487
2500
  if artwork is None: