plexbar 0.1.2__tar.gz → 0.1.4__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.
- {plexbar-0.1.2 → plexbar-0.1.4}/PKG-INFO +19 -1
- {plexbar-0.1.2 → plexbar-0.1.4}/README.md +17 -0
- {plexbar-0.1.2 → plexbar-0.1.4}/pyproject.toml +8 -8
- {plexbar-0.1.2 → plexbar-0.1.4}/src/plexbar/app.py +64 -1
- {plexbar-0.1.2 → plexbar-0.1.4}/src/plexbar/models.py +1 -0
- {plexbar-0.1.2 → plexbar-0.1.4}/src/plexbar/playback.py +12 -2
- {plexbar-0.1.2 → plexbar-0.1.4}/src/plexbar/plex_client.py +12 -0
- {plexbar-0.1.2 → plexbar-0.1.4}/src/plexbar/__init__.py +0 -0
- {plexbar-0.1.2 → plexbar-0.1.4}/src/plexbar/__main__.py +0 -0
- {plexbar-0.1.2 → plexbar-0.1.4}/src/plexbar/settings.py +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: plexbar
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A command-line TUI music player for Plex
|
|
5
5
|
Author: Christopher Patti
|
|
6
6
|
Author-email: Christopher Patti <feoh@feoh.org>
|
|
7
7
|
Requires-Dist: platformdirs>=4.10.0
|
|
8
8
|
Requires-Dist: plexapi>=4.18.1
|
|
9
9
|
Requires-Dist: textual>=8.2.7
|
|
10
|
+
Requires-Dist: textual-image>=0.13.2
|
|
10
11
|
Requires-Python: >=3.12
|
|
11
12
|
Description-Content-Type: text/markdown
|
|
12
13
|
|
|
@@ -16,12 +17,15 @@ Plexbar is a keyboard-driven terminal music player for Plex. It gives you a
|
|
|
16
17
|
simple Textual TUI for browsing and playing your Plex music library without
|
|
17
18
|
opening a web browser.
|
|
18
19
|
|
|
20
|
+

|
|
21
|
+
|
|
19
22
|
## Features
|
|
20
23
|
|
|
21
24
|
- First-run Plex connection setup
|
|
22
25
|
- Browse by Artists, Albums, Tracks, and Playlists
|
|
23
26
|
- Search your Plex music library
|
|
24
27
|
- Queue tracks, albums, artists, and playlists
|
|
28
|
+
- Show cover art for the currently playing track or album
|
|
25
29
|
- Play, pause/resume, stop, and skip with keyboard shortcuts
|
|
26
30
|
- Local playback through `mpv`
|
|
27
31
|
|
|
@@ -33,6 +37,20 @@ opening a web browser.
|
|
|
33
37
|
- A Plex server with at least one music library
|
|
34
38
|
- A Plex token
|
|
35
39
|
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Install Plexbar as a standalone command with `uv`:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv tool install plexbar
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Run Plexbar:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
plexbar
|
|
52
|
+
```
|
|
53
|
+
|
|
36
54
|
## Installation for development
|
|
37
55
|
|
|
38
56
|
Clone the repository and sync dependencies with `uv`:
|
|
@@ -4,12 +4,15 @@ Plexbar is a keyboard-driven terminal music player for Plex. It gives you a
|
|
|
4
4
|
simple Textual TUI for browsing and playing your Plex music library without
|
|
5
5
|
opening a web browser.
|
|
6
6
|
|
|
7
|
+

|
|
8
|
+
|
|
7
9
|
## Features
|
|
8
10
|
|
|
9
11
|
- First-run Plex connection setup
|
|
10
12
|
- Browse by Artists, Albums, Tracks, and Playlists
|
|
11
13
|
- Search your Plex music library
|
|
12
14
|
- Queue tracks, albums, artists, and playlists
|
|
15
|
+
- Show cover art for the currently playing track or album
|
|
13
16
|
- Play, pause/resume, stop, and skip with keyboard shortcuts
|
|
14
17
|
- Local playback through `mpv`
|
|
15
18
|
|
|
@@ -21,6 +24,20 @@ opening a web browser.
|
|
|
21
24
|
- A Plex server with at least one music library
|
|
22
25
|
- A Plex token
|
|
23
26
|
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Install Plexbar as a standalone command with `uv`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv tool install plexbar
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Run Plexbar:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
plexbar
|
|
39
|
+
```
|
|
40
|
+
|
|
24
41
|
## Installation for development
|
|
25
42
|
|
|
26
43
|
Clone the repository and sync dependencies with `uv`:
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "plexbar"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4"
|
|
4
4
|
description = "A command-line TUI music player for Plex"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Christopher Patti", email = "feoh@feoh.org" }]
|
|
7
7
|
requires-python = ">=3.12"
|
|
8
|
-
dependencies = [
|
|
8
|
+
dependencies = [
|
|
9
|
+
"platformdirs>=4.10.0",
|
|
10
|
+
"plexapi>=4.18.1",
|
|
11
|
+
"textual>=8.2.7",
|
|
12
|
+
"textual-image>=0.13.2",
|
|
13
|
+
]
|
|
9
14
|
|
|
10
15
|
[project.scripts]
|
|
11
16
|
plexbar = "plexbar.app:main"
|
|
@@ -15,9 +20,4 @@ requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
|
15
20
|
build-backend = "uv_build"
|
|
16
21
|
|
|
17
22
|
[dependency-groups]
|
|
18
|
-
dev = [
|
|
19
|
-
"mypy>=2.1.0",
|
|
20
|
-
"pre-commit>=4.6.0",
|
|
21
|
-
"pytest>=9.0.3",
|
|
22
|
-
"ruff>=0.15.16",
|
|
23
|
-
]
|
|
23
|
+
dev = ["mypy>=2.1.0", "pre-commit>=4.6.0", "pytest>=9.0.3", "ruff>=0.15.16"]
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Textual application for Plexbar."""
|
|
2
2
|
|
|
3
3
|
from collections.abc import Callable
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from urllib.request import urlopen
|
|
4
6
|
|
|
5
7
|
from textual import on, work
|
|
6
8
|
from textual.app import App, ComposeResult
|
|
@@ -17,6 +19,7 @@ from textual.widgets import (
|
|
|
17
19
|
ListView,
|
|
18
20
|
Static,
|
|
19
21
|
)
|
|
22
|
+
from textual_image.widget import AutoImage
|
|
20
23
|
|
|
21
24
|
from plexbar.models import BrowserItem, ItemKind, QueueTrack
|
|
22
25
|
from plexbar.playback import MpvNotFoundError, MpvPlayer, PlaybackQueue
|
|
@@ -164,6 +167,12 @@ class PlexbarApp(App[None]):
|
|
|
164
167
|
border-top: solid $primary;
|
|
165
168
|
}
|
|
166
169
|
|
|
170
|
+
#cover-art {
|
|
171
|
+
width: 100%;
|
|
172
|
+
height: 18;
|
|
173
|
+
margin: 0 1 1 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
167
176
|
.panel-title {
|
|
168
177
|
text-style: bold;
|
|
169
178
|
padding: 0 1;
|
|
@@ -187,6 +196,7 @@ class PlexbarApp(App[None]):
|
|
|
187
196
|
self.client: PlexMusicClient | None = None
|
|
188
197
|
self.player: MpvPlayer | None = None
|
|
189
198
|
self.queue = PlaybackQueue()
|
|
199
|
+
self.current_track: QueueTrack | None = None
|
|
190
200
|
self.history: list[list[BrowserItem]] = []
|
|
191
201
|
self.items: list[BrowserItem] = []
|
|
192
202
|
|
|
@@ -199,6 +209,7 @@ class PlexbarApp(App[None]):
|
|
|
199
209
|
yield ListView(id="browser-list")
|
|
200
210
|
with Vertical(id="side"):
|
|
201
211
|
yield Label("Now Playing", classes="panel-title")
|
|
212
|
+
yield AutoImage(id="cover-art")
|
|
202
213
|
yield Static("Nothing playing", id="now-playing")
|
|
203
214
|
yield Label("Queue", classes="panel-title")
|
|
204
215
|
yield Static("Queue is empty", id="queue")
|
|
@@ -207,6 +218,8 @@ class PlexbarApp(App[None]):
|
|
|
207
218
|
|
|
208
219
|
def on_mount(self) -> None:
|
|
209
220
|
self.query_one("#search", Input).display = False
|
|
221
|
+
self.query_one("#cover-art", AutoImage).display = False
|
|
222
|
+
self.set_interval(0.5, self.advance_finished_track)
|
|
210
223
|
if config_exists():
|
|
211
224
|
try:
|
|
212
225
|
self.start_with_config(load_config())
|
|
@@ -344,9 +357,22 @@ class PlexbarApp(App[None]):
|
|
|
344
357
|
self.player.pause_resume()
|
|
345
358
|
|
|
346
359
|
def action_next_track(self) -> None:
|
|
360
|
+
self.play_next_track("End of queue.")
|
|
361
|
+
|
|
362
|
+
def advance_finished_track(self) -> None:
|
|
363
|
+
"""Continue playback when mpv exits at the end of a track."""
|
|
364
|
+
|
|
365
|
+
if self.player is None or not self.player.reap_finished():
|
|
366
|
+
return
|
|
367
|
+
self.play_next_track("End of queue.")
|
|
368
|
+
|
|
369
|
+
def play_next_track(self, end_status: str) -> None:
|
|
370
|
+
"""Advance the queue and play the next track, if any."""
|
|
371
|
+
|
|
347
372
|
track = self.queue.next()
|
|
348
373
|
if track is None:
|
|
349
|
-
self.
|
|
374
|
+
self.clear_now_playing()
|
|
375
|
+
self.set_status(end_status)
|
|
350
376
|
self.refresh_queue()
|
|
351
377
|
return
|
|
352
378
|
self.play(track)
|
|
@@ -355,6 +381,7 @@ class PlexbarApp(App[None]):
|
|
|
355
381
|
def action_stop(self) -> None:
|
|
356
382
|
if self.player is not None:
|
|
357
383
|
self.player.stop()
|
|
384
|
+
self.clear_now_playing()
|
|
358
385
|
self.set_status("Stopped.")
|
|
359
386
|
|
|
360
387
|
def append_item(self, item: BrowserItem) -> None:
|
|
@@ -372,10 +399,46 @@ class PlexbarApp(App[None]):
|
|
|
372
399
|
if self.player is None:
|
|
373
400
|
self.set_status("mpv is not available.")
|
|
374
401
|
return
|
|
402
|
+
self.current_track = track
|
|
375
403
|
self.player.play(track)
|
|
376
404
|
self.query_one("#now-playing", Static).update(track.label)
|
|
405
|
+
self.show_cover_art(track)
|
|
377
406
|
self.set_status(f"Playing {track.label}")
|
|
378
407
|
|
|
408
|
+
def show_cover_art(self, track: QueueTrack) -> None:
|
|
409
|
+
"""Load and display the current track's Plex artwork."""
|
|
410
|
+
|
|
411
|
+
cover_art = self.query_one("#cover-art", AutoImage)
|
|
412
|
+
cover_art.image = None
|
|
413
|
+
cover_art.display = False
|
|
414
|
+
if track.artwork_url:
|
|
415
|
+
self._load_cover_art(track.artwork_url)
|
|
416
|
+
|
|
417
|
+
@work(thread=True)
|
|
418
|
+
def _load_cover_art(self, artwork_url: str) -> None:
|
|
419
|
+
try:
|
|
420
|
+
with urlopen(artwork_url, timeout=10) as response:
|
|
421
|
+
image_bytes = response.read()
|
|
422
|
+
except Exception: # noqa: BLE001 - missing artwork should not stop playback
|
|
423
|
+
return
|
|
424
|
+
self.call_from_thread(self._set_cover_art, artwork_url, image_bytes)
|
|
425
|
+
|
|
426
|
+
def _set_cover_art(self, artwork_url: str, image_bytes: bytes) -> None:
|
|
427
|
+
if self.current_track is None or self.current_track.artwork_url != artwork_url:
|
|
428
|
+
return
|
|
429
|
+
cover_art = self.query_one("#cover-art", AutoImage)
|
|
430
|
+
cover_art.image = BytesIO(image_bytes)
|
|
431
|
+
cover_art.display = True
|
|
432
|
+
|
|
433
|
+
def clear_now_playing(self) -> None:
|
|
434
|
+
"""Clear the now-playing label and cover art."""
|
|
435
|
+
|
|
436
|
+
self.current_track = None
|
|
437
|
+
self.query_one("#now-playing", Static).update("Nothing playing")
|
|
438
|
+
cover_art = self.query_one("#cover-art", AutoImage)
|
|
439
|
+
cover_art.image = None
|
|
440
|
+
cover_art.display = False
|
|
441
|
+
|
|
379
442
|
def focused_item(self) -> BrowserItem | None:
|
|
380
443
|
browser = self.query_one("#browser-list", ListView)
|
|
381
444
|
row = browser.highlighted_child
|
|
@@ -29,9 +29,10 @@ class PlaybackQueue:
|
|
|
29
29
|
def append(self, tracks: list[QueueTrack]) -> None:
|
|
30
30
|
"""Append tracks to the queue."""
|
|
31
31
|
|
|
32
|
+
first_new_index = len(self.tracks)
|
|
32
33
|
self.tracks.extend(tracks)
|
|
33
|
-
if self.current_index == -1 and
|
|
34
|
-
self.current_index =
|
|
34
|
+
if self.current_index == -1 and tracks:
|
|
35
|
+
self.current_index = first_new_index
|
|
35
36
|
|
|
36
37
|
def replace(self, tracks: list[QueueTrack]) -> QueueTrack | None:
|
|
37
38
|
"""Replace the queue and return the first track."""
|
|
@@ -100,6 +101,15 @@ class MpvPlayer:
|
|
|
100
101
|
self._process.stdin.flush()
|
|
101
102
|
self._paused = not self._paused
|
|
102
103
|
|
|
104
|
+
def reap_finished(self) -> bool:
|
|
105
|
+
"""Clear and report a naturally finished mpv process."""
|
|
106
|
+
|
|
107
|
+
if self._process is None or self._process.poll() is None:
|
|
108
|
+
return False
|
|
109
|
+
self._process = None
|
|
110
|
+
self._paused = False
|
|
111
|
+
return True
|
|
112
|
+
|
|
103
113
|
def stop(self) -> None:
|
|
104
114
|
"""Stop mpv if it is running."""
|
|
105
115
|
|
|
@@ -136,6 +136,7 @@ class PlexMusicClient:
|
|
|
136
136
|
artist=_safe_title(track, "grandparentTitle"),
|
|
137
137
|
album=_safe_title(track, "parentTitle"),
|
|
138
138
|
stream_url=track.getStreamURL(),
|
|
139
|
+
artwork_url=_artwork_url(track),
|
|
139
140
|
)
|
|
140
141
|
|
|
141
142
|
def _default_music_library(self, preferred_name: str | None) -> MusicSection:
|
|
@@ -184,6 +185,17 @@ def _safe_title(item: Any, attr: str) -> str:
|
|
|
184
185
|
return str(getattr(item, attr, "") or "")
|
|
185
186
|
|
|
186
187
|
|
|
188
|
+
def _artwork_url(track: Track) -> str:
|
|
189
|
+
for attr in ("squareArtUrl", "thumbUrl", "artUrl"):
|
|
190
|
+
try:
|
|
191
|
+
url = getattr(track, attr, None)
|
|
192
|
+
except Exception: # noqa: BLE001 - artwork is optional metadata
|
|
193
|
+
continue
|
|
194
|
+
if url:
|
|
195
|
+
return str(url)
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
|
|
187
199
|
def _is_track(item: Any) -> bool:
|
|
188
200
|
return isinstance(item, Track) or getattr(item, "TYPE", None) == "track"
|
|
189
201
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|