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.
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: plexbar
3
- Version: 0.1.2
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
+ ![PlexBar screenshot](PlexBar.png)
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
+ ![PlexBar screenshot](PlexBar.png)
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.2"
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 = ["platformdirs>=4.10.0", "plexapi>=4.18.1", "textual>=8.2.7"]
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.set_status("End of queue.")
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
@@ -48,6 +48,7 @@ class QueueTrack:
48
48
  artist: str
49
49
  album: str
50
50
  stream_url: str
51
+ artwork_url: str = ""
51
52
 
52
53
  @property
53
54
  def label(self) -> str:
@@ -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 self.tracks:
34
- self.current_index = 0
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