plex-tui 0.3.37__tar.gz → 0.3.41__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 (50) hide show
  1. {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/post-release-homebrew.yml +25 -0
  2. {plex_tui-0.3.37 → plex_tui-0.3.41}/AGENTS.md +5 -0
  3. {plex_tui-0.3.37 → plex_tui-0.3.41}/CHANGELOG.md +14 -0
  4. {plex_tui-0.3.37 → plex_tui-0.3.41}/PKG-INFO +13 -10
  5. {plex_tui-0.3.37 → plex_tui-0.3.41}/README.md +12 -9
  6. {plex_tui-0.3.37 → plex_tui-0.3.41}/ROADMAP.md +3 -2
  7. {plex_tui-0.3.37 → plex_tui-0.3.41}/docs/plex-integration-research.md +18 -30
  8. {plex_tui-0.3.37 → plex_tui-0.3.41}/packaging/aur/.SRCINFO +3 -3
  9. {plex_tui-0.3.37 → plex_tui-0.3.41}/packaging/aur/PKGBUILD +2 -2
  10. {plex_tui-0.3.37 → plex_tui-0.3.41}/pyproject.toml +1 -1
  11. {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/__init__.py +1 -1
  12. {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/app.py +18 -5
  13. {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/player.py +25 -7
  14. {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_app_helpers.py +5 -2
  15. {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_app_navigation.py +81 -0
  16. {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_player.py +63 -4
  17. {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/aur.yml +0 -0
  18. {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/bump.yml +0 -0
  19. {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/ci.yml +0 -0
  20. {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/post-release-aur.yml +0 -0
  21. {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/publish-aur.yml +0 -0
  22. {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/publish-pypi.yml +0 -0
  23. {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/publish-testpypi.yml +0 -0
  24. {plex_tui-0.3.37 → plex_tui-0.3.41}/.gitignore +0 -0
  25. {plex_tui-0.3.37 → plex_tui-0.3.41}/LICENSE +0 -0
  26. {plex_tui-0.3.37 → plex_tui-0.3.41}/Makefile +0 -0
  27. {plex_tui-0.3.37 → plex_tui-0.3.41}/PACKAGING.md +0 -0
  28. {plex_tui-0.3.37 → plex_tui-0.3.41}/RELEASE.md +0 -0
  29. {plex_tui-0.3.37 → plex_tui-0.3.41}/config.example.toml +0 -0
  30. {plex_tui-0.3.37 → plex_tui-0.3.41}/docs/assets/grid-view.png +0 -0
  31. {plex_tui-0.3.37 → plex_tui-0.3.41}/docs/assets/list-view.png +0 -0
  32. {plex_tui-0.3.37 → plex_tui-0.3.41}/packaging/aur/README.md +0 -0
  33. {plex_tui-0.3.37 → plex_tui-0.3.41}/packaging/homebrew/README.md +0 -0
  34. {plex_tui-0.3.37 → plex_tui-0.3.41}/scripts/check_release.py +0 -0
  35. {plex_tui-0.3.37 → plex_tui-0.3.41}/scripts/stage_release.py +0 -0
  36. {plex_tui-0.3.37 → plex_tui-0.3.41}/scripts/update_aur_package.py +0 -0
  37. {plex_tui-0.3.37 → plex_tui-0.3.41}/scripts/update_homebrew_formula.py +0 -0
  38. {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/__main__.py +0 -0
  39. {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/artwork.py +0 -0
  40. {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/auth.py +0 -0
  41. {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/config.py +0 -0
  42. {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/models.py +0 -0
  43. {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/plex_service.py +0 -0
  44. {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/smoke.py +0 -0
  45. {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_artwork.py +0 -0
  46. {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_auth.py +0 -0
  47. {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_cli.py +0 -0
  48. {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_config.py +0 -0
  49. {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_plex_service.py +0 -0
  50. {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_release_checks.py +0 -0
@@ -101,6 +101,31 @@ jobs:
101
101
  brew tap so1omon563/plex-tui "https://x-access-token:${PACKAGING_PR_TOKEN}@github.com/so1omon563/homebrew-plex-tui.git"
102
102
  echo "path=$(brew --repo so1omon563/plex-tui)" >> "$GITHUB_OUTPUT"
103
103
 
104
+ - name: Wait for PyPI package availability
105
+ if: steps.release.outputs.publish == 'true'
106
+ env:
107
+ VERSION: ${{ steps.release.outputs.version }}
108
+ run: |
109
+ set -euo pipefail
110
+
111
+ for attempt in {1..30}; do
112
+ if python -m pip install \
113
+ --dry-run \
114
+ --ignore-installed \
115
+ --no-cache-dir \
116
+ --disable-pip-version-check \
117
+ "plex-tui==${VERSION}" >/dev/null 2>&1; then
118
+ echo "plex-tui ${VERSION} is available from PyPI."
119
+ exit 0
120
+ fi
121
+
122
+ echo "plex-tui ${VERSION} is not available from PyPI yet; retrying (${attempt}/30)."
123
+ sleep 10
124
+ done
125
+
126
+ echo "plex-tui ${VERSION} did not become available from PyPI in time." >&2
127
+ exit 1
128
+
104
129
  - name: Update formula source
105
130
  if: steps.release.outputs.publish == 'true'
106
131
  run: |
@@ -218,6 +218,11 @@ GitHub Release publishing is controlled by release markers in PR titles only:
218
218
  unless the merge should publish PyPI, Homebrew, and AUR. PR bodies may mention
219
219
  release markers for explanation without publishing.
220
220
 
221
+ Post-release package workflows may depend on newly published PyPI artifacts.
222
+ Keep Homebrew tap publication waiting for `pip` to resolve the new
223
+ `plex-tui==VERSION` before updating formula resources, so PyPI indexing lag does
224
+ not break otherwise successful releases.
225
+
221
226
  GitHub CLI notes:
222
227
 
223
228
  - `gh auth status` may fail inside the sandbox even when the user is logged in
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.41 - 2026-06-16
6
+
7
+ - Fixed Plex-transcoded resume playback by passing the resume offset into the
8
+ Plex stream request instead of relying only on mpv seeking.
9
+ - Split playback controls so `p` starts selected media from the beginning and
10
+ `r` resumes from the saved Plex position when available.
11
+
12
+ ## 0.3.39 - 2026-06-16
13
+
14
+ - Fixed playback resume so videos launched from in-progress rows start at the
15
+ displayed Plex resume position even when full metadata reloads omit it.
16
+ - Made post-release Homebrew publishing wait for PyPI package availability before
17
+ resolving formula resources.
18
+
5
19
  ## 0.3.37 - 2026-06-15
6
20
 
7
21
  - Fell back to direct Plex root checks during browser login when PlexAPI's
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plex-tui
3
- Version: 0.3.37
3
+ Version: 0.3.41
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
@@ -197,19 +197,21 @@ See `config.example.toml` for optional settings.
197
197
  ## Playback
198
198
 
199
199
  Playback is launched through `mpv`; plex-tui does not embed a video player.
200
- While playback is active, Plex progress is updated in the background. Saved
201
- audio/subtitle language preferences are applied when matching streams are
202
- available, and the details pane shows the effective playback choices. Playback
203
- mode defaults to direct/default behavior and can be changed in Settings to force
204
- Plex transcoding with Original, 1080p 8 Mbps, 720p 4 Mbps, or 480p 2 Mbps
205
- quality presets.
200
+ Use `p` to start the selected item from the beginning, or `r` to resume from
201
+ the saved Plex position when one is available. While playback is active, Plex
202
+ progress is updated in the background. Saved audio/subtitle language
203
+ preferences are applied when matching streams are available, and the details
204
+ pane shows the effective playback choices. Playback mode defaults to
205
+ direct/default behavior and can be changed in Settings to force Plex
206
+ transcoding with Original, 1080p 8 Mbps, 720p 4 Mbps, or 480p 2 Mbps quality
207
+ presets.
206
208
 
207
209
  ## Key Bindings
208
210
 
209
211
  | Key | Action |
210
212
  | --- | --- |
211
213
  | `q` | Quit |
212
- | `r` | Reload Plex connection |
214
+ | `ctrl+r` | Reload Plex connection |
213
215
  | `/` | Search current library |
214
216
  | `g` | Search all libraries |
215
217
  | `?` | Show help |
@@ -221,7 +223,8 @@ quality presets.
221
223
  | `,` | Show settings |
222
224
  | `escape` | Clear search, go back, or close current view |
223
225
  | `enter` | Open selected item |
224
- | `p` | Play selected item with `mpv` |
226
+ | `p` | Play selected item from the beginning |
227
+ | `r` | Resume selected item from the saved Plex position |
225
228
  | `a` / `s` | Choose audio / subtitle preference |
226
229
  | `A` / `S` | Clear audio preference / cycle subtitle mode |
227
230
  | `x` | Stop launched `mpv` |
@@ -234,7 +237,7 @@ quality presets.
234
237
  - List view plus configurable-density grid view with terminal poster artwork.
235
238
  - External subtitle support and direct playback for embedded PGS/VOBSUB tracks.
236
239
  - Audio and subtitle pickers with saved language preferences.
237
- - Plex resume support and playback progress reporting.
240
+ - Separate play-from-start and resume actions with Plex progress reporting.
238
241
  - Settings screen for stream preferences, playback mode and transcode quality,
239
242
  artwork modes, grid density, page size, auto-load threshold, grid artwork
240
243
  prefetching, media view, and `mpv` window size.
@@ -160,19 +160,21 @@ See `config.example.toml` for optional settings.
160
160
  ## Playback
161
161
 
162
162
  Playback is launched through `mpv`; plex-tui does not embed a video player.
163
- While playback is active, Plex progress is updated in the background. Saved
164
- audio/subtitle language preferences are applied when matching streams are
165
- available, and the details pane shows the effective playback choices. Playback
166
- mode defaults to direct/default behavior and can be changed in Settings to force
167
- Plex transcoding with Original, 1080p 8 Mbps, 720p 4 Mbps, or 480p 2 Mbps
168
- quality presets.
163
+ Use `p` to start the selected item from the beginning, or `r` to resume from
164
+ the saved Plex position when one is available. While playback is active, Plex
165
+ progress is updated in the background. Saved audio/subtitle language
166
+ preferences are applied when matching streams are available, and the details
167
+ pane shows the effective playback choices. Playback mode defaults to
168
+ direct/default behavior and can be changed in Settings to force Plex
169
+ transcoding with Original, 1080p 8 Mbps, 720p 4 Mbps, or 480p 2 Mbps quality
170
+ presets.
169
171
 
170
172
  ## Key Bindings
171
173
 
172
174
  | Key | Action |
173
175
  | --- | --- |
174
176
  | `q` | Quit |
175
- | `r` | Reload Plex connection |
177
+ | `ctrl+r` | Reload Plex connection |
176
178
  | `/` | Search current library |
177
179
  | `g` | Search all libraries |
178
180
  | `?` | Show help |
@@ -184,7 +186,8 @@ quality presets.
184
186
  | `,` | Show settings |
185
187
  | `escape` | Clear search, go back, or close current view |
186
188
  | `enter` | Open selected item |
187
- | `p` | Play selected item with `mpv` |
189
+ | `p` | Play selected item from the beginning |
190
+ | `r` | Resume selected item from the saved Plex position |
188
191
  | `a` / `s` | Choose audio / subtitle preference |
189
192
  | `A` / `S` | Clear audio preference / cycle subtitle mode |
190
193
  | `x` | Stop launched `mpv` |
@@ -197,7 +200,7 @@ quality presets.
197
200
  - List view plus configurable-density grid view with terminal poster artwork.
198
201
  - External subtitle support and direct playback for embedded PGS/VOBSUB tracks.
199
202
  - Audio and subtitle pickers with saved language preferences.
200
- - Plex resume support and playback progress reporting.
203
+ - Separate play-from-start and resume actions with Plex progress reporting.
201
204
  - Settings screen for stream preferences, playback mode and transcode quality,
202
205
  artwork modes, grid density, page size, auto-load threshold, grid artwork
203
206
  prefetching, media view, and `mpv` window size.
@@ -53,8 +53,9 @@
53
53
  server response.
54
54
  - Alphabet navigation is available for loaded browse lists and grids with
55
55
  previous/next section jumps.
56
- - Explicit playback controls are available for direct/default playback versus
57
- selected transcode quality presets.
56
+ - Explicit playback controls are available for play-from-start versus resume,
57
+ direct/default playback versus selected transcode quality presets, and
58
+ Plex-side resume offsets for transcoded streams.
58
59
  - Later design passes: profile switching with auto sign-in, visible movie
59
60
  edition handling, and in-playback audio/subtitle switching through mpv IPC.
60
61
 
@@ -38,54 +38,42 @@ Already covered:
38
38
 
39
39
  - Plex PIN login and server selection.
40
40
  - Library list browsing with paged loading and automatic load-more.
41
+ - Continue Watching as a top-level browse entry.
42
+ - Selective library display in Settings.
43
+ - Library submenus for Library, Recommended, Collections, and Playlists.
44
+ - Alphabet navigation for loaded browse lists and grids.
41
45
  - Current-library and global search.
42
46
  - Show/season/episode child browsing through PlexAPI child helpers.
43
- - Resume offset playback and progress reporting.
47
+ - Separate play-from-start and resume controls, including Plex-side resume
48
+ offsets for transcoded streams and progress reporting.
44
49
  - Saved audio/subtitle language preferences plus per-item stream pickers.
45
50
  - Direct playback paths where possible, with transcode fallback through PlexAPI.
51
+ - Explicit video quality/direct/transcode preference in Settings and playback.
46
52
  - Details pane metadata, audio/subtitle stream display, and playback readiness.
47
53
 
48
54
  Not yet covered:
49
55
 
50
- - Continue Watching as a top-level browse entry.
51
- - Selective library display in Settings.
52
56
  - User/profile switching and auto sign-in.
53
- - Library submenus for Recommended, Collections, Playlists, and Categories.
54
- - Alphabet side navigation for full-library browsing.
57
+ - Library Categories where PlexAPI exposes them cleanly.
55
58
  - Movie editions as distinct, visible item variants.
56
- - Explicit video quality/direct/transcode preference in Settings and playback.
57
59
  - In-playback audio/subtitle switching after mpv has launched.
58
60
 
59
61
  ## Recommended Sequence
60
62
 
61
- 1. Continue Watching entrypoint.
62
- Add a top-level browse row backed by Plex in-progress/on-deck style data.
63
- This is the highest-value 240-MP idea because it is small, terminal-friendly,
64
- and matches how users resume media.
63
+ 1. Library Categories.
64
+ Add Categories to the library submenu after mapping PlexAPI support against
65
+ real server responses. Keep the default browse path simple so
66
+ Enter-on-library behavior stays fast.
65
67
 
66
- 2. Selective library visibility.
67
- Add a Settings multiselect-style workflow for hiding noisy Plex libraries.
68
- This reduces browse clutter without changing playback behavior.
69
-
70
- 3. Library submenu mode.
71
- Add optional library entrypoints for Library, Collections, Playlists,
72
- Categories, and Recommended where Plex exposes them. Keep the default path
73
- simple so existing Enter-on-library behavior stays fast.
74
-
75
- 4. Alphabet navigation for full-library views.
76
- Add a keyboard-friendly jump mode for large libraries. This should come after
77
- library submenu work so it is only active in full-library lists.
78
-
79
- 5. Playback quality controls.
80
- Add a setting for direct/default versus selected transcode qualities. This
81
- needs careful testing because plex-tui currently relies on PlexAPI stream URL
82
- behavior and direct track arguments.
83
-
84
- 6. Profile switching and auto sign-in.
68
+ 2. Profile switching and auto sign-in.
85
69
  Useful, but more invasive than the browse entrypoints because it changes auth
86
70
  and account-token assumptions. Treat it as a separate design pass.
87
71
 
88
- 7. In-playback track switching.
72
+ 3. Movie editions.
73
+ Surface editions as distinct item variants once enough real-library examples
74
+ are available to keep the UI compact.
75
+
76
+ 4. In-playback track switching.
89
77
  Defer until there is a clear design for driving mpv IPC track changes and
90
78
  reconciling Plex stream state after launch.
91
79
 
@@ -1,6 +1,6 @@
1
1
  pkgbase = plex-tui
2
2
  pkgdesc = Standalone Plex terminal UI with mpv playback
3
- pkgver = 0.3.35
3
+ pkgver = 0.3.39
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.35.tar.gz::https://github.com/so1omon563/plex-tui/archive/refs/tags/v0.3.35.tar.gz
20
- sha256sums = 9ef3e6be46a7fe3176c9d69dc491a006054afe82c79618d9ee2117a088a067fc
19
+ source = plex-tui-0.3.39.tar.gz::https://github.com/so1omon563/plex-tui/archive/refs/tags/v0.3.39.tar.gz
20
+ sha256sums = 1e799c3f9635dc8e36b1da8ace89a1c3776fb9414a40e74c635f803864c48fad
21
21
 
22
22
  pkgname = plex-tui
@@ -1,6 +1,6 @@
1
1
  # Maintainer: so1omon
2
2
  pkgname=plex-tui
3
- pkgver=0.3.35
3
+ pkgver=0.3.39
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=("9ef3e6be46a7fe3176c9d69dc491a006054afe82c79618d9ee2117a088a067fc")
25
+ sha256sums=("1e799c3f9635dc8e36b1da8ace89a1c3776fb9414a40e74c635f803864c48fad")
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.37"
7
+ version = "0.3.41"
8
8
  description = "A small standalone Plex terminal UI."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """Plex TUI prototype."""
2
2
 
3
- __version__ = "0.3.37"
3
+ __version__ = "0.3.41"
@@ -56,6 +56,7 @@ from .player import (
56
56
  play_with_mpv,
57
57
  preferred_audio_choice,
58
58
  preferred_subtitle_choice,
59
+ resume_offset_ms,
59
60
  same_stream,
60
61
  stop_mpv,
61
62
  stream_language_key,
@@ -430,7 +431,7 @@ class PlexTuiApp(App[None]):
430
431
 
431
432
  BINDINGS = [
432
433
  Binding("q", "quit", "Quit"),
433
- Binding("r", "reload", "Reload"),
434
+ Binding("ctrl+r", "reload", "Reload"),
434
435
  Binding("/", "focus_search", "Search"),
435
436
  Binding("g", "focus_global_search", "Global"),
436
437
  Binding("tab", "focus_next", "Next"),
@@ -447,6 +448,7 @@ class PlexTuiApp(App[None]):
447
448
  Binding("comma", "show_settings", "Settings"),
448
449
  Binding("escape", "back_or_clear", "Back"),
449
450
  Binding("p", "play_selected", "Play"),
451
+ Binding("r", "resume_selected", "Resume"),
450
452
  Binding("a", "audio_picker", "Audio"),
451
453
  Binding("s", "subtitle_picker", "Subtitles"),
452
454
  Binding("A", "clear_audio_preference", "Clear audio"),
@@ -2194,6 +2196,12 @@ class PlexTuiApp(App[None]):
2194
2196
  self.open_library_menu(state.selected_library)
2195
2197
 
2196
2198
  def action_play_selected(self) -> None:
2199
+ self.play_selected_media(resume=False)
2200
+
2201
+ def action_resume_selected(self) -> None:
2202
+ self.play_selected_media(resume=True)
2203
+
2204
+ def play_selected_media(self, resume: bool) -> None:
2197
2205
  media = self.selected_media()
2198
2206
  if media is None:
2199
2207
  self.set_status("No media selected")
@@ -2201,6 +2209,9 @@ class PlexTuiApp(App[None]):
2201
2209
  if not media.playable:
2202
2210
  self.set_status("Selected item is not directly playable")
2203
2211
  return
2212
+ if resume and not resume_offset_ms(media.raw):
2213
+ self.set_status("No resume position for selected media; press p to play from the beginning")
2214
+ return
2204
2215
  subtitle_choice = preferred_subtitle_choice(
2205
2216
  media.raw,
2206
2217
  self.config.preferred_subtitle_language,
@@ -2216,6 +2227,7 @@ class PlexTuiApp(App[None]):
2216
2227
  window_size=self.config.mpv_window_size,
2217
2228
  playback_mode=self.config.playback_mode,
2218
2229
  transcode_quality=self.config.transcode_quality,
2230
+ resume=resume,
2219
2231
  )
2220
2232
  except PlayerError as exc:
2221
2233
  self.clear_playback_footer()
@@ -2816,7 +2828,8 @@ def render_help() -> str:
2816
2828
  "g: search all libraries",
2817
2829
  "",
2818
2830
  "Playback",
2819
- "p: play selected media with mpv",
2831
+ "p: play selected media from beginning",
2832
+ "r: resume selected media",
2820
2833
  "x: stop launched mpv",
2821
2834
  "",
2822
2835
  "Streams",
@@ -2827,7 +2840,7 @@ def render_help() -> str:
2827
2840
  "",
2828
2841
  "Settings",
2829
2842
  ",: show settings",
2830
- "r: reconnect / reload libraries",
2843
+ "ctrl+r: reconnect / reload libraries",
2831
2844
  "PLEX_TUI_PERF_LOG=1: write browsing timings and navigation diagnostics",
2832
2845
  "PLEX_TUI_ARTWORK_LOG=1: include verbose grid artwork internals",
2833
2846
  "?: show help",
@@ -2885,12 +2898,12 @@ def context_hint(row: object) -> str:
2885
2898
  return "Media: Enter loads next page"
2886
2899
  if isinstance(row, MediaRow):
2887
2900
  if row.media.playable:
2888
- return "Media: Enter selects / p plays / a audio / s subtitles"
2901
+ return "Media: Enter selects / p plays from start / r resumes / a audio / s subtitles"
2889
2902
  return "Media: Enter opens item"
2890
2903
  if isinstance(row, MediaGrid):
2891
2904
  media = row.selected_media
2892
2905
  if media is not None and media.playable:
2893
- return "Grid: Arrows/page select card / p plays / a audio / s subtitles"
2906
+ return "Grid: Arrows/page select card / p plays from start / r resumes / a audio / s subtitles"
2894
2907
  return "Grid: Arrows/page select card / Enter opens item"
2895
2908
  if isinstance(row, ServerRow):
2896
2909
  return "Servers: Enter selects server"
@@ -60,11 +60,13 @@ def play_with_mpv(
60
60
  window_size: str = "",
61
61
  playback_mode: str = "auto",
62
62
  transcode_quality: str = "original",
63
+ resume: bool = True,
63
64
  ) -> PlayerHandle:
64
65
  if shutil.which("mpv") is None:
65
66
  log_debug("playback error: mpv was not found in PATH")
66
67
  raise PlayerError("mpv was not found in PATH. Install mpv and make sure it is available on PATH.")
67
68
 
69
+ selected_start_offset = resume_offset_ms(item)
68
70
  item = full_metadata(item)
69
71
  selected_subtitle = resolve_subtitle_choice(item, subtitle_choice)
70
72
  selected_audio = resolve_audio_choice(item, audio_choice)
@@ -80,6 +82,11 @@ def play_with_mpv(
80
82
  if force_transcode:
81
83
  stream_kwargs.update(transcode_quality_kwargs(transcode_quality))
82
84
 
85
+ title = getattr(item, "title", "Plex")
86
+ start_offset = (resume_offset_ms(item) or selected_start_offset) if resume else 0
87
+ if start_offset and direct_url is None:
88
+ stream_kwargs["offset"] = plex_stream_offset(start_offset)
89
+
83
90
  try:
84
91
  url = direct_url or item.getStreamURL(**stream_kwargs)
85
92
  except Exception as exc:
@@ -89,15 +96,15 @@ def play_with_mpv(
89
96
  log_debug("playback error: Plex returned an empty stream URL")
90
97
  raise PlayerError("Plex returned an empty stream URL")
91
98
 
92
- title = getattr(item, "title", "Plex")
93
- start_offset = resume_offset_ms(item)
99
+ stream_mode = "direct" if direct_url else "transcode"
100
+ monitor_base_offset = start_offset if stream_mode == "transcode" and stream_kwargs.get("offset") else 0
94
101
  socket_path = Path(tempfile.gettempdir()) / f"plex-tui-mpv-{os.getpid()}-{int(time.time() * 1000)}.sock"
95
102
  args = [
96
103
  "mpv",
97
104
  "--force-media-title=" + title,
98
105
  "--input-ipc-server=" + str(socket_path),
99
106
  ]
100
- if start_offset:
107
+ if start_offset and not monitor_base_offset:
101
108
  args.append(f"--start={start_offset / 1000:.3f}")
102
109
  if window_size:
103
110
  args.append(f"--autofit={window_size}")
@@ -106,7 +113,6 @@ def play_with_mpv(
106
113
  args.append("--sub-file=" + subtitle)
107
114
  args.append(url)
108
115
  command = sanitize_command(args)
109
- stream_mode = "direct" if direct_url else "transcode"
110
116
  quality_label = transcode_quality_label(transcode_quality) if stream_mode == "transcode" else "original"
111
117
  log_debug(
112
118
  "launching mpv: "
@@ -127,7 +133,7 @@ def play_with_mpv(
127
133
  log_debug(f"playback error: failed to launch mpv: {exc}; args={command!r}")
128
134
  raise PlayerError(f"failed to launch mpv: {exc}") from exc
129
135
  subtitle_count = active_subtitle_count(item, selected_subtitle)
130
- monitor = ProgressMonitor(item, process, socket_path, start_offset)
136
+ monitor = ProgressMonitor(item, process, socket_path, start_offset, base_offset=monitor_base_offset)
131
137
  monitor.start()
132
138
  return PlayerHandle(
133
139
  title=title,
@@ -142,11 +148,19 @@ def play_with_mpv(
142
148
 
143
149
 
144
150
  class ProgressMonitor:
145
- def __init__(self, item: Any, process: subprocess.Popen[bytes], socket_path: Path, start_offset: int) -> None:
151
+ def __init__(
152
+ self,
153
+ item: Any,
154
+ process: subprocess.Popen[bytes],
155
+ socket_path: Path,
156
+ start_offset: int,
157
+ base_offset: int = 0,
158
+ ) -> None:
146
159
  self.item = item
147
160
  self.process = process
148
161
  self.socket_path = socket_path
149
162
  self.last_ms = start_offset
163
+ self.base_offset = base_offset
150
164
  self._stop = threading.Event()
151
165
  self._thread = threading.Thread(target=self._run, name="plex-tui-progress", daemon=True)
152
166
 
@@ -186,7 +200,7 @@ class ProgressMonitor:
186
200
  if value is None:
187
201
  return None
188
202
  try:
189
- return max(0, int(float(value) * 1000))
203
+ return max(0, self.base_offset + int(float(value) * 1000))
190
204
  except (TypeError, ValueError):
191
205
  return None
192
206
 
@@ -235,6 +249,10 @@ def resume_offset_ms(item: Any) -> int:
235
249
  return 0
236
250
 
237
251
 
252
+ def plex_stream_offset(milliseconds: int) -> int:
253
+ return max(0, milliseconds // 1000)
254
+
255
+
238
256
  def full_metadata(item: Any) -> Any:
239
257
  if not hasattr(item, "reload"):
240
258
  return item
@@ -823,6 +823,9 @@ def test_render_help_groups_key_bindings():
823
823
  assert "d: focus details" in rendered
824
824
  assert "v: toggle list/grid view" in rendered
825
825
  assert "left/right: move across grid cards" in rendered
826
+ assert "p: play selected media from beginning" in rendered
827
+ assert "r: resume selected media" in rendered
828
+ assert "ctrl+r: reconnect / reload libraries" in rendered
826
829
  assert "PLEX_TUI_ARTWORK_LOG=1" in rendered
827
830
  assert "?: show help" in rendered
828
831
 
@@ -1024,9 +1027,9 @@ def test_context_hints_for_media_and_load_more():
1024
1027
  setting_action = next(row for row in settings if getattr(row, "action", "") == "cycle_grid_density")
1025
1028
  setting_value = next(row for row in settings if getattr(row, "label_text", "").strip().startswith("Server:"))
1026
1029
 
1027
- assert context_hint(MediaRow(playable)) == "Media: Enter selects / p plays / a audio / s subtitles"
1030
+ assert context_hint(MediaRow(playable)) == "Media: Enter selects / p plays from start / r resumes / a audio / s subtitles"
1028
1031
  assert context_hint(MediaRow(container)) == "Media: Enter opens item"
1029
- assert context_hint(grid) == "Grid: Arrows/page select card / p plays / a audio / s subtitles"
1032
+ assert context_hint(grid) == "Grid: Arrows/page select card / p plays from start / r resumes / a audio / s subtitles"
1030
1033
  assert context_hint(LoadMoreRow(100, 200)) == "Media: Enter loads next page"
1031
1034
  assert context_hint(LibraryMenuRow(LibraryItem("Movies", "1", "movie", object()), "library", "Library", "All items")) == (
1032
1035
  "Library: Enter opens browse mode"
@@ -405,6 +405,18 @@ def test_playback_footer_shows_active_playback():
405
405
  asyncio.run(run_playback_footer_check())
406
406
 
407
407
 
408
+ def test_playback_action_starts_from_beginning():
409
+ asyncio.run(run_playback_starts_from_beginning_check())
410
+
411
+
412
+ def test_resume_action_uses_saved_position():
413
+ asyncio.run(run_resume_action_check())
414
+
415
+
416
+ def test_resume_action_requires_saved_position():
417
+ asyncio.run(run_resume_requires_position_check())
418
+
419
+
408
420
  def test_quick_preference_actions_update_config():
409
421
  asyncio.run(run_quick_preference_action_check())
410
422
 
@@ -1757,6 +1769,7 @@ async def run_playback_footer_check():
1757
1769
 
1758
1770
  assert launch.call_args.kwargs["playback_mode"] == "transcode"
1759
1771
  assert launch.call_args.kwargs["transcode_quality"] == "720p_4"
1772
+ assert launch.call_args.kwargs["resume"] is False
1760
1773
  footer = app.query_one("#playback-footer")
1761
1774
  assert footer.display
1762
1775
  assert footer.content == (
@@ -1766,6 +1779,74 @@ async def run_playback_footer_check():
1766
1779
  )
1767
1780
 
1768
1781
 
1782
+ async def run_playback_starts_from_beginning_check():
1783
+ class ResumableRaw(Raw):
1784
+ viewOffset = 65_000
1785
+
1786
+ app = PlexTuiApp()
1787
+ async with app.run_test() as pilot:
1788
+ await pilot.pause(1.0)
1789
+ app.config = AppConfig("http://plex", "token", "client-id")
1790
+ app.show_media("Movies", [MediaItem("Movie", "", "movie", "1", True, ResumableRaw())])
1791
+ await pilot.pause(0.2)
1792
+
1793
+ player = SimpleNamespace(
1794
+ title="Movie",
1795
+ start_offset_ms=0,
1796
+ stream_mode="transcode",
1797
+ subtitle_count=0,
1798
+ process=SimpleNamespace(poll=lambda: None),
1799
+ )
1800
+ with patch("plextui.app.play_with_mpv", return_value=player) as launch:
1801
+ app.action_play_selected()
1802
+ await pilot.pause(0.2)
1803
+
1804
+ assert launch.call_args.kwargs["resume"] is False
1805
+ assert "resume" not in app.query_one("#playback-footer").content
1806
+
1807
+
1808
+ async def run_resume_action_check():
1809
+ class ResumableRaw(Raw):
1810
+ viewOffset = 65_000
1811
+
1812
+ app = PlexTuiApp()
1813
+ async with app.run_test() as pilot:
1814
+ await pilot.pause(1.0)
1815
+ app.config = AppConfig("http://plex", "token", "client-id")
1816
+ app.show_media("Movies", [MediaItem("Movie", "", "movie", "1", True, ResumableRaw())])
1817
+ await pilot.pause(0.2)
1818
+
1819
+ player = SimpleNamespace(
1820
+ title="Movie",
1821
+ start_offset_ms=65_000,
1822
+ stream_mode="transcode",
1823
+ subtitle_count=0,
1824
+ process=SimpleNamespace(poll=lambda: None),
1825
+ )
1826
+ with patch("plextui.app.play_with_mpv", return_value=player) as launch:
1827
+ app.action_resume_selected()
1828
+ await pilot.pause(0.2)
1829
+
1830
+ assert launch.call_args.kwargs["resume"] is True
1831
+ assert "resume 1:05" in app.query_one("#playback-footer").content
1832
+
1833
+
1834
+ async def run_resume_requires_position_check():
1835
+ app = PlexTuiApp()
1836
+ async with app.run_test() as pilot:
1837
+ await pilot.pause(1.0)
1838
+ app.config = AppConfig("http://plex", "token", "client-id")
1839
+ app.show_media("Movies", [MediaItem("Movie", "", "movie", "1", True, Raw())])
1840
+ await pilot.pause(0.2)
1841
+
1842
+ with patch("plextui.app.play_with_mpv") as launch:
1843
+ app.action_resume_selected()
1844
+ await pilot.pause(0.2)
1845
+
1846
+ assert not launch.called
1847
+ assert app.query_one("#status").content == "No resume position for selected media; press p to play from the beginning"
1848
+
1849
+
1769
1850
  async def run_quick_preference_action_check():
1770
1851
  app = PlexTuiApp()
1771
1852
  async with app.run_test() as pilot:
@@ -9,6 +9,7 @@ from plextui.player import (
9
9
  PlayerError,
10
10
  ProgressMonitor,
11
11
  StreamChoice,
12
+ plex_stream_offset,
12
13
  play_with_mpv,
13
14
  preferred_audio_choice,
14
15
  preferred_subtitle_choice,
@@ -112,12 +113,54 @@ def test_playback_applies_selected_streams_and_resume_offset(debug_log_path):
112
113
  args = popen.call_args.args[0]
113
114
  assert handle.start_offset_ms == 65000
114
115
  assert handle.command[0] == "mpv"
115
- assert "--start=65.000" in args
116
+ assert "--start=65.000" not in args
116
117
  assert "--sub-file=http://plex/library/streams/1" in args
117
- assert item.kwargs == {"audioStreamID": 42}
118
+ assert item.kwargs == {"audioStreamID": 42, "offset": 65}
118
119
  assert "launching mpv" in debug_log_path.read_text(encoding="utf-8")
119
120
 
120
121
 
122
+ def test_playback_can_start_from_beginning_instead_of_resuming():
123
+ item = Item()
124
+
125
+ with (
126
+ patch("plextui.player.shutil.which", return_value="/usr/bin/mpv"),
127
+ patch("plextui.player.ProgressMonitor.start"),
128
+ patch("plextui.player.subprocess.Popen", return_value=Proc()) as popen,
129
+ ):
130
+ handle = play_with_mpv(item, resume=False)
131
+
132
+ args = popen.call_args.args[0]
133
+ assert handle.start_offset_ms == 0
134
+ assert "--start=65.000" not in args
135
+ assert item.kwargs == {}
136
+
137
+
138
+ def test_playback_keeps_selected_resume_offset_when_reload_omits_it():
139
+ class FullItem(Item):
140
+ viewOffset = 0
141
+
142
+ class BrowseItem(Item):
143
+ def __init__(self):
144
+ self.full_item = FullItem()
145
+
146
+ def reload(self):
147
+ return self.full_item
148
+
149
+ item = BrowseItem()
150
+
151
+ with (
152
+ patch("plextui.player.shutil.which", return_value="/usr/bin/mpv"),
153
+ patch("plextui.player.ProgressMonitor.start"),
154
+ patch("plextui.player.subprocess.Popen", return_value=Proc()) as popen,
155
+ ):
156
+ handle = play_with_mpv(item)
157
+
158
+ args = popen.call_args.args[0]
159
+ assert handle.start_offset_ms == 65000
160
+ assert "--start=65.000" not in args
161
+ assert item.full_item.kwargs == {"offset": 65}
162
+
163
+
121
164
  def test_playback_applies_configured_mpv_window_size():
122
165
  item = Item()
123
166
 
@@ -144,7 +187,7 @@ def test_subtitle_none_disables_subtitle_selection():
144
187
 
145
188
  args = popen.call_args.args[0]
146
189
  assert handle.subtitle_count == 0
147
- assert item.kwargs == {"subtitleStreamID": 0}
190
+ assert item.kwargs == {"subtitleStreamID": 0, "offset": 65}
148
191
  assert not any(arg.startswith("--sub-file=") for arg in args)
149
192
 
150
193
 
@@ -159,6 +202,19 @@ def test_progress_monitor_reports_progress_and_timeline():
159
202
  assert item.timeline == (123000, "playing")
160
203
 
161
204
 
205
+ def test_progress_monitor_adds_base_offset_to_transcode_time():
206
+ item = Item()
207
+ monitor = ProgressMonitor(item, Proc(), Path("/tmp/socket"), 65000, base_offset=65000)
208
+
209
+ with patch("plextui.player.mpv_get_property", return_value=12.5):
210
+ assert monitor.current_time_ms() == 77500
211
+
212
+
213
+ def test_plex_stream_offset_converts_milliseconds_to_seconds():
214
+ assert plex_stream_offset(65_999) == 65
215
+ assert plex_stream_offset(-1) == 0
216
+
217
+
162
218
  def test_direct_play_gets_mpv_track_hints_for_embedded_streams():
163
219
  class EmbeddedSubtitle(SubtitleStream):
164
220
  key = None
@@ -190,6 +246,7 @@ def test_direct_play_gets_mpv_track_hints_for_embedded_streams():
190
246
 
191
247
  args = popen.call_args.args[0]
192
248
  assert handle.stream_mode == "direct"
249
+ assert "--start=65.000" in args
193
250
  assert "--aid=1" in args
194
251
  assert "--sid=1" in args
195
252
 
@@ -229,8 +286,10 @@ def test_force_transcode_bypasses_direct_play_and_applies_quality():
229
286
  "subtitleStreamID": 1,
230
287
  "maxVideoBitrate": 4000,
231
288
  "videoResolution": "1280x720",
289
+ "offset": 65,
232
290
  }
233
291
  assert not any(arg.startswith("--sid=") for arg in args)
292
+ assert "--start=65.000" not in args
234
293
 
235
294
 
236
295
  def test_force_transcode_original_quality_omits_quality_kwargs():
@@ -244,7 +303,7 @@ def test_force_transcode_original_quality_omits_quality_kwargs():
244
303
  handle = play_with_mpv(item, playback_mode="transcode", transcode_quality="original")
245
304
 
246
305
  assert handle.stream_mode == "transcode"
247
- assert item.kwargs == {}
306
+ assert item.kwargs == {"offset": 65}
248
307
 
249
308
 
250
309
  def test_preferred_choices_match_stream_language():
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes