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.
- {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/post-release-homebrew.yml +25 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/AGENTS.md +5 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/CHANGELOG.md +14 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/PKG-INFO +13 -10
- {plex_tui-0.3.37 → plex_tui-0.3.41}/README.md +12 -9
- {plex_tui-0.3.37 → plex_tui-0.3.41}/ROADMAP.md +3 -2
- {plex_tui-0.3.37 → plex_tui-0.3.41}/docs/plex-integration-research.md +18 -30
- {plex_tui-0.3.37 → plex_tui-0.3.41}/packaging/aur/.SRCINFO +3 -3
- {plex_tui-0.3.37 → plex_tui-0.3.41}/packaging/aur/PKGBUILD +2 -2
- {plex_tui-0.3.37 → plex_tui-0.3.41}/pyproject.toml +1 -1
- {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/__init__.py +1 -1
- {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/app.py +18 -5
- {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/player.py +25 -7
- {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_app_helpers.py +5 -2
- {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_app_navigation.py +81 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_player.py +63 -4
- {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/aur.yml +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/bump.yml +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/ci.yml +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/post-release-aur.yml +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/publish-aur.yml +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/publish-pypi.yml +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/.github/workflows/publish-testpypi.yml +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/.gitignore +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/LICENSE +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/Makefile +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/PACKAGING.md +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/RELEASE.md +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/config.example.toml +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/docs/assets/grid-view.png +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/docs/assets/list-view.png +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/packaging/aur/README.md +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/packaging/homebrew/README.md +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/scripts/check_release.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/scripts/stage_release.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/scripts/update_aur_package.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/scripts/update_homebrew_formula.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/__main__.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/artwork.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/auth.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/config.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/models.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/plex_service.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/src/plextui/smoke.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_artwork.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_auth.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_cli.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_config.py +0 -0
- {plex_tui-0.3.37 → plex_tui-0.3.41}/tests/test_plex_service.py +0 -0
- {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.
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
62
|
-
Add
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
20
|
-
sha256sums =
|
|
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.
|
|
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=("
|
|
25
|
+
sha256sums=("1e799c3f9635dc8e36b1da8ace89a1c3776fb9414a40e74c635f803864c48fad")
|
|
26
26
|
|
|
27
27
|
build() {
|
|
28
28
|
cd "$pkgname-$pkgver"
|
|
@@ -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
|
|
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
|
-
|
|
93
|
-
|
|
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__(
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|