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.
- {plex_tui-0.3.28 → plex_tui-0.3.35}/AGENTS.md +24 -2
- {plex_tui-0.3.28 → plex_tui-0.3.35}/CHANGELOG.md +16 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/Makefile +5 -1
- {plex_tui-0.3.28 → plex_tui-0.3.35}/PACKAGING.md +5 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/PKG-INFO +5 -1
- {plex_tui-0.3.28 → plex_tui-0.3.35}/README.md +4 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/RELEASE.md +8 -5
- {plex_tui-0.3.28 → plex_tui-0.3.35}/packaging/aur/.SRCINFO +3 -3
- {plex_tui-0.3.28 → plex_tui-0.3.35}/packaging/aur/PKGBUILD +2 -2
- {plex_tui-0.3.28 → plex_tui-0.3.35}/pyproject.toml +1 -1
- plex_tui-0.3.35/scripts/stage_release.py +215 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/__init__.py +1 -1
- {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/app.py +25 -12
- plex_tui-0.3.35/src/plextui/auth.py +204 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/plex_service.py +36 -11
- {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_app_helpers.py +9 -5
- plex_tui-0.3.35/tests/test_auth.py +145 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_plex_service.py +20 -1
- {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_release_checks.py +91 -0
- plex_tui-0.3.28/src/plextui/auth.py +0 -94
- plex_tui-0.3.28/tests/test_auth.py +0 -30
- {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/aur.yml +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/bump.yml +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/ci.yml +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/post-release-aur.yml +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/post-release-homebrew.yml +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/publish-aur.yml +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/publish-pypi.yml +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/.github/workflows/publish-testpypi.yml +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/.gitignore +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/LICENSE +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/ROADMAP.md +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/config.example.toml +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/docs/assets/grid-view.png +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/docs/assets/list-view.png +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/docs/plex-integration-research.md +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/packaging/aur/README.md +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/packaging/homebrew/README.md +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/scripts/check_release.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/scripts/update_aur_package.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/scripts/update_homebrew_formula.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/__main__.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/artwork.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/config.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/models.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/player.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/src/plextui/smoke.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_app_navigation.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_artwork.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_cli.py +0 -0
- {plex_tui-0.3.28 → plex_tui-0.3.35}/tests/test_config.py +0 -0
- {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
|
|
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.
|
|
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
|
-
-
|
|
24
|
-
|
|
25
|
-
-
|
|
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.
|
|
35
|
-
|
|
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.
|
|
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
|
-
sha256sums =
|
|
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.
|
|
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=("
|
|
25
|
+
sha256sums=("89cf8080c48c7d57a0bf9f4e6b273e9842fef7ecfdf783bf3add6c2425a30569")
|
|
26
26
|
|
|
27
27
|
build() {
|
|
28
28
|
cd "$pkgname-$pkgver"
|
|
@@ -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())
|
|
@@ -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
|
-
|
|
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(
|
|
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:
|